赞
踩
登录和用户认证是一个网站最基本的功能,在这篇博客里,将介绍如何用SpringBoot整合Spring Security + Redis实现登录及用户认证
本文参考了以下两篇文章:
Spring Security一一认证、授权的工作原理
【全网最细致】SpringBoot整合Spring Security + JWT实现用户认证
各位可以去看看
那我们现在开始吧,(~* - *)~
图里的具体流程可以从上面我引入的文章中获取,此处不做赘述。
我具体讲讲一下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
中。这样登录的认证状态就被维持了。
<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>
认证成功处理器 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);
}
}
验证码配置类 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); } }
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(); } }
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); } }
我们之前放行了匿名请求,但有的接口是需要权限的,当用户权限不足时,会进入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();
}
}
/** * 退出成功处理器 * <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(); } }
直接上代码!!!
@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(); // } }
就此大功告成了。
前端需要做两件事,一是登录成功后把
token
存到localStore
里面。
二是在每次请求之前,都在请求头中添加token
。
还要注意:在发送登录表单信息时,需要将userKey一同返回,这样后端才能去redis中获取到对应的验证码做校验
security
的话,那么整个流程应该如下:验证码流程
登录认证流程
再次访问,维持登录状态流程
好了所有的流程就都结束了,这里最后解释一下,为什么要将用户的信息也存入redis,而不是直接转化为JWT,将JWT作为一个Token来处理,了解JWT的盆友都知道,JWT由三部分组成,且都是必要部分,即使只存储一个字符串,JWT整体都会显得比较臃肿,每次都需要将这样一个臃肿的字段写入请求头发送的话,会造成无意义的开销,so,秉着只传输必要部分,尽量精简token的思路,我们仅将一个redis存储的key作为token使用。
萌新宝宝第一次写CSDN,应该会有不少纰漏,请各路大佬指正,CU。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。