Spring Boot整合Spring Security + Redis实现用户认证
登录和用户认证是一个网站最基本的功能,在这篇博客里,将介绍如何用SpringBoot整合Spring Security + Redis实现登录及用户认证
本文参考了以下两篇文章:
Spring Security一一认证、授权的工作原理
【全网最细致】SpringBoot整合Spring Security + JWT实现用户认证
各位可以去看看
那我们现在开始吧,(~* - *)~
文章目录
- Spring Boot整合Spring Security + Redis实现用户认证
- 一、Spring Security认证流程
- 二、正式开始整合Spring Security 和 Redis
- 2.写登录认证成功、失败处理器LoginSuccessHandle、LoginfailureHandle
- 3.编写验证码配置、接口、过滤器
- 4.Token过滤器`TokenAuthenticationFilter`以及Token认证失败过滤器`TokenAuthenticationEntryPoint`
- 5. 基于数据库信息做认证,UserDetails、UserServiceDetails、LoginFilter.
- 6、权限异常处理AccessDenieHandler
- 7、登出处理器AccessDenieHandler
- 8、!!!整合所有组件,Spring Security全局配置:SecurityConfig!!!
- 9、前端需要做什么?
- 10、若先不考虑`security`的话,那么整个流程应该如下:
一、Spring Security认证流程
图里的具体流程可以从上面我引入的文章中获取,此处不做赘述。
我具体讲讲一下Autentication
存入SecurityContextHolder中的过程。
- 首先,
SecurityContextHolder
只是为SecurityContext
提供一种存储策略,只是主导了他的存储方式及地址。- 从源码可以看到
SecurityContextHolder
提供了一个SecurityContextHolderStrategy
存储策略进行上下文的存储,进入到Security ContextHolderStrategy
接口,共有三个实现类。分别对应三种存储策略,分别对应threadlocal
,global
,InheritableThreadLocal
三种方式。由源码我们可以得出SecurityContextHolder
默认使用的是THREADLOCAL模式。- 默认是将
SecurityContext
存储在threadlocal
中,可能是spring考虑到目前大多数为BS应用,一个应用同时可能有多个使用者,每个使用者又对应不同的安全上下,Security Context Holder为了保存这些安全上下文。- 缺省情况下,使用了
ThreadLocal
机制来保存每个使用者的安全上下文。因为缺省情况下根据Servlet规范,一个Servlet request的处理不管经历了多少个Filter,自始至终都由同一个线程来完成。这样就很好的保证了其安全性。- 但是当我们开发的是一个CS本地应用的时候,这种模式就不太适用了。spring早早的就考虑到了这种情况,这个时候我们就可以设置为Global模式仅使用一个变量来存储
SecurityContext
。比如还有其他的一些应用会有自己的线程创建,并且希望这些新建线程也能使用创建者的安全上下文。这种效果,我们就可以通过将SecurityContextHolder
配置成MODE_INHERITABLETHREADLOCAL策略达到。
那么security是如何通过SessionId来维持登录状态的呢。
- 在认证完成后,信息完整的
Authentication
会保存至安全上下文SecurityContext
中,然后SecurityContext
会引用SecurityContextHelder
中的存储策略存储在本地线程ThreadLocal
中去。- 然后当
login
这个request
结束时,ThreadLocal
即将被销毁,若如此做,那么认证信息就丢失了,等于就是白登录了,所以security
在request
结束前会将ThreadLocal
中的内容存储到session
中去,同时获取到对应的sessionId
存放在cookie
返回给前端。- 当前端再次发送请求时,就会在
cookie
中携带sessionId
,后端接收后获取到对应的session
内容,转存到ThreadLocal
,这样认证信息就被保持在了一个新的request
中。这样登录的认证状态就被维持了。
二、正式开始整合Spring Security 和 Redis
1.pom.xml添加所需要的依赖
<dependencies>
<!-- dataRedis起步依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Radis连接池-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- security起步依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- springWeb起步依赖:开启springMVC功能-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- druid数据库连接池 起步依赖-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.16</version>
</dependency>
<!-- mybatis起步依赖-->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.1</version>
</dependency>
<!-- mysql数据库驱动-->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- mybatis-plus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.3</version>
</dependency>
<!--hutool 万能工具包-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.16</version>
</dependency>
<!--验证码工具类-->
<dependency>
<groupId>com.github.axet</groupId>
<artifactId>kaptcha</artifactId>
<version>0.0.9</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
2.写登录认证成功、失败处理器LoginSuccessHandle、LoginfailureHandle
认证成功处理器 LoginSuccessHandle
这个类就是在登录成功后,自动生成一个随机Token
,以Token
为key
,登录信息为value
,缓存到redis
中,然后将该Token
返回给前端存储,约定好前端发送的后续request
需要在请求头中携带此Token
作为凭证才能访问。
@Slf4j
@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
private final StringRedisTemplate stringRedisTemplate;
private final AccountMapper accountMapper;
private final RoleMapper roleMapper;
public LoginSuccessHandler(StringRedisTemplate stringRedisTemplate, AccountMapper accountMapper, RoleMapper roleMapper) {
this.stringRedisTemplate = stringRedisTemplate;
this.accountMapper = accountMapper;
this.roleMapper = roleMapper;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
System.out.println("=====================LoginSuccessHandler=========================");
response.setContentType("application/json;charset=UTF-8");
ServletOutputStream outputStream = response.getOutputStream();
//1.生成tokenId
String token = UUID.randomUUID().toString(true);
//2.将account对象转化为hashMap存储
//--2.1先从security中生成的authentication对象中获取到account信息
Account account = accountMapper.selectByUsername(authentication.getName());
List<Role> roles = roleMapper.selectByAccountId(account.getAccountId());
//--2.2将密码赋值为空串,消除敏感信息
account.setPassword("");
//--2.3将account转化为HashMap
AccountDTO accountDTO = BeanUtil.copyProperties(account, AccountDTO.class);
Map<String, Object> accountMap = BeanUtil.beanToMap(accountDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((field, fieldValue) -> fieldValue.toString()));
//--2.4将roles转化为json数据;
String rolesJson = JSONUtil.toJsonStr(roles);
//3.存储到redis中去
log.info("存入redis数据:{}", accountMap);
String accountKey = RedisConst.LOGIN_ACCOUNT_KEY + token;
String rolesKey = RedisConst.LOGIN_ROLES_KEY + token;
stringRedisTemplate.opsForHash().putAll(accountKey, accountMap);
stringRedisTemplate.opsForValue().set(rolesKey, rolesJson, RedisConst.LOGIN_ACCOUNT_TTL, TimeUnit.MINUTES);
//4.设置有效期
stringRedisTemplate.expire(accountKey, RedisConst.LOGIN_ACCOUNT_TTL, TimeUnit.MINUTES);
//.将token存入Result
log.info("Token:{}", token);
Result result = Result.ok("登录成功", token);
//5.返回Token
outputStream.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
outputStream.flush();
outputStream.close();
}
}
认证失败处理器 LoginFailureHandler
这个类就是在登录失败后将失败的信息返回给前端处理。
@Component
public class LoginFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
PrintWriter pw = response.getWriter();
String msg = "用户名或密码错误";
Result result;
if (exception instanceof CaptchaException) {
msg = exception.getMessage();
}
result = Result.unAuthentication(msg);
pw.println(JSONUtil.toJsonStr(result));
pw.flush();
pw.close();
}
}
此外,我们还需要定义一个验证码错误异常:
public class CaptchaException extends AuthenticationException {
public CaptchaException(String msg) {
super(msg);
}
}
3.编写验证码配置、接口、过滤器
验证码配置类 KaptchaConfig
验证码使用的是谷歌的验证码工具类,pom.xml已经引入了依赖。
DefaultKaptcha实现了Producer接口,Producer接口用于生成验证码,调用其createText()方法即可生成字符串验证码。
配置如下:
@Configuration
public class KaptchaConfig {
@Bean
DefaultKaptcha producer() {
Properties properties = new Properties();
properties.put("kaptcha.border", "no");
properties.put("kaptcha.textproducer.font.color", "black");
properties.put("kaptcha.textproducer.char.space", "4");
properties.put("kaptcha.image.height", "40");
properties.put("kaptcha.image.width", "120");
properties.put("kaptcha.textproducer.font.size", "30");
Config config = new Config(properties);
DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
defaultKaptcha.setConfig(config);
return defaultKaptcha;
}
}
生成验证码方法 Captcha()
这个方法就是,生成了一个随机Key和随机Code,将(key,code)传入redis,并将Key和Code的图片的base64编码后的密文返回给前端,约定好前端在登录时,携带验证code的同时需要携带Key,以便于校验code。
@Slf4j
@RestController
public class LoginController {
private final Producer producer;
private final StringRedisTemplate redis;
@Autowired
public LoginController(Producer producer, StringRedisTemplate redis) {
this.producer = producer;
this.redis = redis;
}
@GetMapping("/captcha")
public Result Captcha() throws IOException {
//1.生成随机key
String key = UUID.randomUUID().toString();
//2.生成验证码
String code = producer.createText();
log.info("生成验证码:{}", code);
//3.生成验证码图片
BufferedImage image = producer.createImage(code);
//4.
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ImageIO.write(image, "jpg", outputStream);
//5.设定图片格式,将图片转化为base64编码
Base64.Encoder encoder = Base64.getEncoder();
String str = "data:image/jpeg;base64,";
String base64Img = str + encoder.encodeToString(outputStream.toByteArray());
//6.将数据存储到Redis
redis.opsForValue().set(RedisConst.LOGIN_CODE_KEY + key, code, RedisConst.LOGIN_CODE_TTL, TimeUnit.MINUTES);
//7.返回
HashMap<Object, Object> map = new HashMap<>();
map.put("userKey", key);
map.put("captcherImg", base64Img);
return Result.ok("这是验证码图片发送,前端需要将userKey返回,才能完成校验", map);
}
}
验证码过滤器 CaptchaFiter
在验证码过滤器中,需要先判断请求是否是登录请求,若是登录请求,则进行验证码校验,从redis
中通过userKey
查找对应的验证码,看是否与前端所传验证码参数一致,当校验成功时,因为验证码是一次性使用的,一个验证码对应一个用户的一次登录过程,所以需要将存储在redis的验证码删除。当校验失败时,则交给登录认证失败处理器LoginFailureHandler
进行处理。
使用了一个包装类RequestWrapper
包装request
,使得RequestBody
中的数据能持续获取。
@Slf4j
@Component
public class CaptchaFilter extends OncePerRequestFilter {
private final StringRedisTemplate redis;
private final LoginFailureHandler loginFailureHandler;
@Autowired
public CaptchaFilter(StringRedisTemplate redis, LoginFailureHandler loginFailureHandler) {
this.redis = redis;
this.loginFailureHandler = loginFailureHandler;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
System.out.println("=========================CaptchaFilter=========================");
// SpringBoot也是通过获取request的输入流来获取参数,这样上面的疑问就能解开了,为什么经过过滤器来到Controller请求参数就没了,
// 这是因为 InputStream read方法内部有一个,postion,标志当前流读取到的位置,每读取一次,位置就会移动一次,如果读到最后,InputStream.read方法会返回-1,标志已经读取完了,
// 如果想再次读取,可以调用inputstream.reset方法,position就会移动到上次调用mark的位置,mark默认是0,所以就能从头再读了。
// 但是呢 是否能reset又是由markSupported决定的,为true能reset,为false就不能reset,
// 从源码可以看到,markSupported是为false的,而且一调用reset就是直接异常
// 这个是使用了一层包装类,对request中的InputStream做备份
RequestWrapper requestWrapper = new RequestWrapper(request);
String url = request.getRequestURI();
if ("/login".equals(url) && request.getMethod().equals("POST")) {
// 校验验证码
try {
validate(requestWrapper);
} catch (CaptchaException e) {
// 交给认证失败处理器
loginFailureHandler.onAuthenticationFailure(requestWrapper, response, e);
}
}
log.info("跳过非/login请求");
filterChain.doFilter(requestWrapper, response);
}
// 校验验证码逻辑
private void validate(RequestWrapper request) throws IOException {
System.out.println("=================validate================");
String bodyJson = request.getBodyString();
//1.获取值
HashMap<String, String> userInfo = JSONUtil.parseObj(bodyJson).toBean(HashMap.class);
String userKey = userInfo.get("userKey");
String code = userInfo.get("code");
//2.校验是否有值
if (StrUtil.isBlank(code) || StrUtil.isBlank(userKey)) {
throw new CaptchaException("验证码不存在");
}
//3.从redis中获取到系统生成的验证码
String key = RedisConst.LOGIN_CODE_KEY + userKey;
String redisCode = redis.opsForValue().get(key);
log.info("code:{} ,redisCode:{}", code, redisCode);
//4.校验验证码是否匹配
if (!code.equals(redisCode)) {
throw new CaptchaException("验证码不匹配");
}
//5.若校验成功,那么就需要删除redis中的验证码
redis.opsForValue().getOperations().delete(userKey);
}
}
4.Token过滤器TokenAuthenticationFilter
以及Token认证失败过滤器TokenAuthenticationEntryPoint
Token过滤器
TokenAuthenticationFilter
TokenAuthenticationFilter继承了BasicAuthenticationFilter,该类用于普通http请求进行身份认证,该类有一个重要属性:AuthenticationManager,表示认证管理器,它是一个接口,它的默认实现类是ProviderManager,它与用户名密码认证息息相关。
若Token验证成功·,我们构建了一个UsernamePasswordAuthenticationToken
对象,用于保存用户信息,之后将该对象交给SecurityContextHolder
,set进它的context
中,这样后续我们就能通过调用SecurityContextHolder.getContext().getAuthentication().getPrincipal()
等方法获取到当前登录的用户信息了。
/**
* token 过滤器
* <p>
* 在首次登录成功后,LoginSuccessHandler将生成token,并返回给前端。
* 在之后的所有请求中(包括再次登录请求),都会携带此token信息。
* 我们需要写一个token过滤器TokenAuthenticationFilter,
* 当前端发来的请求有JWT信息时,该过滤器将检验JWT是否正确以及是否过期,
* 若检验成功,则获取token中的信息,组合前缀生成一个key,检索redis数据库获得用户实体类,并将用户信息告知Spring Security,
* 后续我们就能调用security的接口获取到当前登录的用户信息。
* <p>
* 若前端发的请求不含JWT,我们也不能拦截该请求,因为一般的项目都是允许匿名访问的,
* 有的接口允许不登录就能访问,没有JWT也放行是安全的,因为我们可以通过Spring Security进行权限管理,
* 设置一些接口需要权限才能访问,不允许匿名访问
*/
public class TokenAuthenticationFilter extends BasicAuthenticationFilter {
private final StringRedisTemplate redis;
public TokenAuthenticationFilter(AuthenticationManager authenticationManager, StringRedisTemplate redis) {
super(authenticationManager);
this.redis = redis;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
//1.获取前端token,并校验
String token = request.getHeader("token");
if (StrUtil.isBlankOrUndefined(token)) {
//token为空直接放行,让后续过滤器链来执行
chain.doFilter(request, response);
return;
}
//2.获取redis中存储的数据
String accountKey = RedisConst.LOGIN_ACCOUNT_KEY + token;
String rolesKey = RedisConst.LOGIN_ROLES_KEY + token;
Map<Object, Object> accountMap = redis.opsForHash().entries(accountKey);
String rolesJson = redis.opsForValue().get(rolesKey);
//3.判断是否为空,即是否为无效key
if (accountMap.isEmpty()) {
throw new TokenException("token 已过期");
}
//4.若token对应账户不为空,那么将查询出来的对象转化为account对象
Account account = BeanUtil.fillBeanWithMap(accountMap, new Account(), false);
account.setRoles(JSONUtil.toList(rolesJson, Role.class));
//5.构建UsernamePasswordAuthenticationToken,这里密码为null,是因为提供了正确的Token,实现自动登录
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(account.getUsername(), null, new AccountUser(account).getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//6.刷新token有效期
redis.expire(accountKey, RedisConst.LOGIN_ACCOUNT_TTL, MINUTES);
redis.expire(rolesKey, RedisConst.LOGIN_ACCOUNT_TTL, MINUTES);
//7.过滤器继续执行
chain.doFilter(request, response);
}
}
Token认证失败过滤器
TokenAuthenticationEntryPoint
/**
* token验证失败处理类
* <p>
* 当BasicAuthenticationFilter认证失败的时候会进入AuthenticationEntryPoint,
* 我们定义JWT认证失败处理器JwtAuthenticationEntryPoint,使其实现AuthenticationEntryPoint接口,
* 该接口只有一个commence方法,表示认证失败的处理,我们重写该方法,向前端返回错误信息,
* 不论是什么原因,JWT认证失败,我们就要求重新登录,所以返回的错误信息为请先登录
*/
@Component
public class TokenAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
ServletOutputStream outputStream = response.getOutputStream();
Result result = Result.unAuthentication("请先登录");
outputStream.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
outputStream.flush();
outputStream.close();
}
}
5. 基于数据库信息做认证,UserDetails、UserServiceDetails、LoginFilter.
SpringSecurity中的认证管理器AuthenticationManager是一个抽象接口,用以提供各种认证方式。一般我们都使用从数据库中验证用户名、密码是否正确这种认证方式。
AuthenticationManager的默认实现类是ProviderManager,ProviderManager提供很多认证方式,DaoAuthenticationProvider是AuthenticationProvider的一种实现,可以通过实现UserDetailsService接口的方式来实现数据库查询方式登录。
UserDetailsService定义了loadUserByUsername方法,该方法通过用户名去查询出UserDetails并返回,UserDetails是一个接口,实际重写该方法时需要返回它的实现类
Spring Security在拿到UserDetails之后,会去对比Authentication(Authentication如何得到?我们使用的是默认的UsernamePasswordAuthenticationFilter,它会读取表单中的用户信息并生成Authentication),若密码正确,则Spring Secuity自动帮忙完成登录
AccountUser类
UserDetails是一个元数据类,是提供给security做认证的一个权威数据,可以基于多种方式实现,此处我们使用的是基于数据库获取元数据。
public class AccountUser implements UserDetails {
private Account account;
public AccountUser(Account account) {
this.account = account;
}
public Account getAccount() {
return account;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return account.getRoles().stream().map(r -> new SimpleGrantedAuthority(r.getRoleName())).collect(Collectors.toList());
}
@Override
public String getPassword() {
return account.getPassword();
}
@Override
public String getUsername() {
return account.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
/**
* 这是上文提到的Account对象,可以自己任意定义
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Account {
private Long accountId;
private String username;
private String password;
private String phone;
private String email;
private java.sql.Timestamp createTime;
private java.sql.Timestamp updateTime;
private Boolean isDeleted;
private List<Role> roles;
}
UserDetailsServiceImpl
这里就具体实现了从数据库获取数据的过程
/**
* 提供认证元数据
*/
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final AccountMapper accountMapper;
private final RoleMapper roleMapper;
@Autowired
public UserDetailsServiceImpl(AccountMapper accountMapper, RoleMapper roleMapper) {
this.accountMapper = accountMapper;
this.roleMapper = roleMapper;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//1.根据用户名查询账户信息
Account account = accountMapper.selectByUsername(username);
if (account == null) {
throw new UsernameNotFoundException("用户名不存在");
}
//2.获取该用户角色信息
List<Role> roles = roleMapper.selectByAccountId(account.getAccountId());
account.setRoles(roles);
log.info("登录用户信息:{}", account);
return new AccountUser(account);
}
}
LogingFilter
为了适应前后端分离时的登录过程,我们需要重写UsernamePasswordAuthenticationFilter,使得能通过RequestBody中的json获得前端传过来的username,password数据
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
System.out.println("=========================LoginFilter=========================");
// 1.判断是否是post方式请求 ( 这里的操作和UsernamePasswordAuthenticationFilter是一样的 )
if (!request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
// 2.判断是否是json格式请求类别
if (request.getContentType().equalsIgnoreCase(MediaType.APPLICATION_JSON_VALUE)) {
// 3.从json数据中获取用户名和密码进行认证
try {
Map<String, String> userInfo = new ObjectMapper().readValue(request.getInputStream(), Map.class);
String username = userInfo.get(getUsernameParameter());
String password = userInfo.get(getPasswordParameter());
System.out.println("username = " + username);
System.out.println("password = " + password);
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
return super.attemptAuthentication(request, response);
}
}
6、权限异常处理AccessDenieHandler
我们之前放行了匿名请求,但有的接口是需要权限的,当用户权限不足时,会进入AccessDenieHandler进行处理,我们定义JwtAccessDeniedHandler类来实现该接口,需重写其handle方法。
@Component
public class TokenAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
ServletOutputStream outputStream = response.getOutputStream();
Result result = Result.unAuthorization(accessDeniedException.getMessage());
outputStream.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
outputStream.flush();
outputStream.close();
}
}
7、登出处理器AccessDenieHandler
/**
* 退出成功处理器
* <p>
* 我们将我们之前置入SecurityContext中的用户信息进行清除,
* 这可以通过创建SecurityContextLogoutHandler对象,调用它的logout方法完成
* 然后清除redis中保持的对应用户数据
* <p>
* 我们定义LogoutSuccessHandler接口的实现类TokenLogoutSuccessHandler,
* 重写其onLogoutSuccess方法
*/
@Component
public class TokenLogoutSuccessHandler implements LogoutSuccessHandler {
private final StringRedisTemplate redis;
@Autowired
public TokenLogoutSuccessHandler(StringRedisTemplate redis) {
this.redis = redis;
}
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
if (authentication != null) {
new SecurityContextLogoutHandler().logout(request, response, authentication);
//TODO:获取到token信息,删除Redis中的数据
//1.获取到的token信息
String token = request.getHeader("token");
//2.使用token来删除redis数据
redis.opsForHash().getOperations().delete(RedisConst.LOGIN_ACCOUNT_KEY + token);
}
response.setContentType("application/json;charset=UTF-8");
ServletOutputStream outputStream = response.getOutputStream();
Result result = Result.ok("退出成功!");
outputStream.write(JSONUtil.toJsonStr(result).getBytes(StandardCharsets.UTF_8));
outputStream.flush();
outputStream.close();
}
}
8、!!!整合所有组件,Spring Security全局配置:SecurityConfig!!!
直接上代码!!!
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 配置白名单
*/
private static final String[] URL_WHITELIST = {
"/login", "/logout", "/captcha", "/favicon.ico"
};
/**
* 登录失败控制器
*/
private final LoginFailureHandler loginFailureHandler;
/**
* 登录成功控制器
*/
private final LoginSuccessHandler loginSuccessHandler;
/**
* 验证码过滤器
*/
private final CaptchaFilter captchaFilter;
/**
* Token校验失败处理
*/
private final TokenAuthenticationEntryPoint tokenAuthenticationEntryPoint;
/**
* 权限不足处理
*/
private final TokenAccessDeniedHandler tokenAccessDeniedHandler;
/**
* 退出成功处理
*/
private final TokenLogoutSuccessHandler tokenLogoutSuccessHandler;
/**
* 基于mysql数据库的认证元数据获取方式
*/
private final UserDetailsService userDetailsService;
/**
* 引入redis
*/
private final StringRedisTemplate redis;
@Autowired
public MySecurityConfig(LoginFailureHandler loginFailureHandler, LoginSuccessHandler loginSuccessHandler, CaptchaFilter captchaFilter, TokenAuthenticationEntryPoint tokenAuthenticationEntryPoint, TokenAccessDeniedHandler tokenAccessDeniedHandler, TokenLogoutSuccessHandler tokenLogoutSuccessHandler, UserDetailsService userDetailsService, StringRedisTemplate redis) {
this.loginFailureHandler = loginFailureHandler;
this.loginSuccessHandler = loginSuccessHandler;
this.captchaFilter = captchaFilter;
this.tokenAuthenticationEntryPoint = tokenAuthenticationEntryPoint;
this.tokenAccessDeniedHandler = tokenAccessDeniedHandler;
this.tokenLogoutSuccessHandler = tokenLogoutSuccessHandler;
this.userDetailsService = userDetailsService;
this.redis = redis;
}
/**
* 将自定义的userDetailsService配置到认证管理器中去
*
* @param auth the {@link AuthenticationManagerBuilder} to use
* @throws Exception
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}
/**
* 将自定义Manager暴露
*
* @return
* @throws Exception
*/
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
/**
* 纳入登录校验过滤器
*
* @return
* @throws Exception
*/
@Bean
public TokenAuthenticationFilter tokenAuthenticationFilter() throws Exception {
return new TokenAuthenticationFilter(authenticationManagerBean(), redis);
}
// 自定义 filter 交给工厂管理
@Bean
public LoginFilter loginFilter() throws Exception {
LoginFilter loginFilter = new LoginFilter();
//指定认证 url
loginFilter.setFilterProcessesUrl("/login");
//指定接收json中的 用户名/密码 的key
loginFilter.setUsernameParameter("username");
loginFilter.setPasswordParameter("password");
//设置认证数据源
loginFilter.setAuthenticationManager(authenticationManagerBean());
//指定 认证成功/认证失败 处理
loginFilter.setAuthenticationSuccessHandler(loginSuccessHandler);
loginFilter.setAuthenticationFailureHandler(loginFailureHandler);
return loginFilter;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable()
// 登录配置
.formLogin()
.successHandler(loginSuccessHandler)
.failureHandler(loginFailureHandler)
.and()
.logout()
.logoutSuccessHandler(tokenLogoutSuccessHandler)
// 禁用session
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
// 配置拦截规则
.and()
.authorizeRequests()
.antMatchers(URL_WHITELIST).permitAll()
.anyRequest().authenticated()
// 异常处理器
.and()
.exceptionHandling()
.authenticationEntryPoint(tokenAuthenticationEntryPoint)
.accessDeniedHandler(tokenAccessDeniedHandler)
// 配置自定义的过滤器
.and()
.addFilter(tokenAuthenticationFilter())
// 验证码过滤器放在UsernamePassword过滤器之前
.addFilterBefore(captchaFilter, UsernamePasswordAuthenticationFilter.class)
;
//at: 将filter替换过滤器链中的哪个过滤器
//before: 将filter放在过滤器链中哪一个之前
//after: 将filter放在过滤器链中哪一个之后
http.addFilterAt(loginFilter(), UsernamePasswordAuthenticationFilter.class);
}
/**
* 此处配置一个加密方法Bean,指定加密方式,供Security加密,解析取用
* @return
*/
// @Bean
// public PasswordEncoder passwordEncoder() {
// return new BCryptPasswordEncoder();
// }
}
就此大功告成了。
9、前端需要做什么?
前端需要做两件事,一是登录成功后把
token
存到localStore
里面。
二是在每次请求之前,都在请求头中添加token
。
还要注意:在发送登录表单信息时,需要将userKey一同返回,这样后端才能去redis中获取到对应的验证码做校验
10、若先不考虑security
的话,那么整个流程应该如下:
验证码流程
登录认证流程
再次访问,维持登录状态流程
好了所有的流程就都结束了,这里最后解释一下,为什么要将用户的信息也存入redis,而不是直接转化为JWT,将JWT作为一个Token来处理,了解JWT的盆友都知道,JWT由三部分组成,且都是必要部分,即使只存储一个字符串,JWT整体都会显得比较臃肿,每次都需要将这样一个臃肿的字段写入请求头发送的话,会造成无意义的开销,so,秉着只传输必要部分,尽量精简token的思路,我们仅将一个redis存储的key作为token使用。
萌新宝宝第一次写优快云,应该会有不少纰漏,请各路大佬指正,CU。