本文采用springsecurity oauth2 + redis实现单点登录,现在如果想要使用springsecurity实现单点登录的话,比较流行的方法是使用jwt方式来实现,虽然jwt优点很多,本身就能携带很多信息,但它是无状态的,服务端不用保存它的信息,这样就有一个问题,一旦jwt的token发送到用户手中,那么只要token不过期,用户就可以一直访问系统,也就没有退出功能了,如果要实现退出功能,自然就要在服务端存储jwt的信息,就违背了jwt的思想了,redis实现单点登录则不存在这个问题,用户登录成功后用户信息会被存储到redis中,可以实现正常的退出。
在编写认证服务器之前,首先要确定使用哪一种oauth2的认证方式,它们分别是授权码模式,密码模式,简化模式和客户端模式,这里我采用的是密码模式来实现单点登录,理由是密码模式相对于授权码模式来说不需要获取授权码即可获取访问令牌,并且认证服务器和资源服务器都是自己开发的项目,这样是很适合使用密码模式的,授权码模式安全级别较高,也可以使用。
认证服务器的编写是最重要的部分,它负责用户的认证和授权,资源服务器可以有多个,而认证服务器只有一个。
编写完成后的架构
/** * Security 配置类 */ @Configuration @EnableWebSecurity public class SecurityConfiguration extends WebSecurityConfigurerAdapter { // 初始化密码编码器,用BCryptPasswordEncoder加密密码 @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } // 初始化认证管理对象,密码模式需要 @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } // 放行和认证规则 @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .authorizeRequests() // 放行的请求 .antMatchers("/loginController/**").permitAll() .and() .authorizeRequests() // 其他请求必须认证才能访问 .anyRequest().authenticated(); } }
@Configuration public class RedisTokenStoreConfig { // 注入 Redis 连接工厂 @Autowired private RedisConnectionFactory redisConnectionFactory; // 初始化RedisTokenStore 用于将 token 存储至 Redis @Bean public RedisTokenStore redisTokenStore() { RedisTokenStore redisTokenStore = new RedisTokenStore(redisConnectionFactory); redisTokenStore.setPrefix("TOKEN:"); // 设置key的层级前缀,方便查询 return redisTokenStore; } }
@Component public class CustomerUserDetailService implements UserDetailsService { @Autowired private PasswordEncoder passwordEncoder; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { return User.withUsername("yuki").password(passwordEncoder.encode("123456")).roles("TEACHER").build(); } }
@Configuration //开启授权服务器 @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Autowired private PasswordEncoder passwordEncoder; @Autowired private AuthenticationManager authenticationManager; @Autowired private RedisTokenStore redisTokenStore; @Autowired private UserDetailsService CustomerUserDetailService; /** * 配置被允许访问此认证服务器的客户端信息 * 1.内存方式 * 2. 数据库方式 * @param clients * @throws Exception */ @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { //暂时先放到内存中 clients.inMemory() //配置客户端id .withClient("WebClient") //配置客户端密钥 .secret(passwordEncoder.encode("123456")) //配置授权范围 .scopes("all") //配置访问令牌过期时间 .accessTokenValiditySeconds(60*100) //配置授权类型 .authorizedGrantTypes("password","refresh_token"); } //参数名称叫授权服务器端点配置器 @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { // password 要这个 AuthenticationManager 实例 endpoints.authenticationManager(authenticationManager) //使用redis方式管理令牌 .tokenStore(redisTokenStore) //启动刷新令牌需要在此处指定UserDetailsService .userDetailsService(CustomerUserDetailService); } @Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { // 开启/oauth/check_token,作用是资源服务器会带着令牌到授权服务器中检查令牌是否正确,然后如果正确授权服务器会给资源服务器返回用户的信息 security.checkTokenAccess("permitAll()"); // 认证后可访问 /oauth/token_key, 默认拒绝访问,作用是获取jwt公钥用来解析jwt // security.tokenKeyAccess("isAuthenticated()"); } }
到了这里,其实认证服务器就已经配置好了,为了测试它的功能,就可以创建controller类进行测试了。
下面的操作都还是在认证服务器中进行,在进行之前先要在配置文件中配置好redis的信息,因为登录要采用redis来做
server.port=9050 #配置单节点的redis服务 spring.redis.host=127.0.0.1 spring.redis.database=0 spring.redis.port=6379
@RestController @RequestMapping("/loginController") @Slf4j public class LoginController { @Autowired private RestTemplate restTemplate; @Autowired private RedisOperator redisOperator; private static final String REDIS_USER_CODEKEY = "verifyCode"; private static final String GETTOKENURL = "http://localhost:9050/oauth/token"; //用户登录接口 @PostMapping("/login") public Result login(String username,String password,String codeKey,String codeKeyIndex){ //通过redis检测验证码是否正确 String verifyCode = redisOperator.get(REDIS_USER_CODEKEY + ":" + codeKeyIndex); log.info("接收到验证码: "+codeKey); log.info("verifyCode: "+verifyCode); if (!codeKey.equals(verifyCode)){ return new Result(HttpServletResponse.SC_FORBIDDEN,null,"验证码不正确"); } // 构建请求头 HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); // 构建请求体(请求参数) MultiValueMap<String,Object> paramsMap=new LinkedMultiValueMap<>(); paramsMap.add("username", username); paramsMap.add("password", password); paramsMap.add("grant_type", "password"); //利用密码模式到sso授权服务器中拿到access_token和refresh_token,因为是同一个项目的模块,可以使用密码模式 //在请求头中带上客户端的账号密码 HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(paramsMap, headers); // 设置 Authorization restTemplate.getInterceptors().add( new BasicAuthenticationInterceptor("WebClient","123456")); ResponseEntity<OAuth2AccessToken> result; try { //发送请求,从TokenEndpoint类中可以看到返回值是OAuth2AccessToken result = restTemplate.postForEntity(GETTOKENURL, entity, OAuth2AccessToken.class); }catch (HttpClientErrorException e){ return new Result(HttpServletResponse.SC_FORBIDDEN,null,e.getMessage()); } //处理返回结果 if (result.getStatusCode()!= HttpStatus.OK){ return new Result(HttpServletResponse.SC_UNAUTHORIZED,null,"登录失败"); } //在这里也可以使用vo对象,封装好前端需要的数据返回,这个token如果以前设置了token加强信息,这里也能获取到 return new Result(HttpServletResponse.SC_OK,result.getBody(),"登录成功"); } //获取验证码的方法,将验证码存储到redis中 @GetMapping("/getVerifyCode") public Result getVerifyCode() throws IOException { //1.生成验证码 String codeKey = VerifyCodeUtils.generateVerifyCode(4); log.info("验证码:" + codeKey); //2.存储验证码 redis String codeKeyIndex = UUID.randomUUID().toString(); //stringRedisTemplate.opsForValue().set(codeKey, code, 60, TimeUnit.SECONDS); redisOperator.set(REDIS_USER_CODEKEY+":"+codeKeyIndex,codeKey,500); //3.base64转换验证码 ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); VerifyCodeUtils.outputImage(120, 60, byteArrayOutputStream, codeKey); String data = "data:image/png;base64," + Base64Utils.encodeToString(byteArrayOutputStream.toByteArray()); //4.响应数据 Map<String, String> map = new HashMap<>(); map.put("data",data); map.put("codeKeyIndex",codeKeyIndex); return new Result(200,map,"获取验证码成功"); } //重新登录,刷新令牌的使用,也要使用客户端id和密码进行查找新的令牌。备用 @GetMapping("/refresh") public Result refresh(@RequestParam("refreshToken") String refreshToken){ //从请求头中解析出refresh_token System.out.println(refreshToken); // 构建请求头 HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); MultiValueMap<String,Object> paramsMap=new LinkedMultiValueMap<>(); paramsMap.add("grant_type", "refresh_token"); paramsMap.add("refresh_token",refreshToken); //在请求头中带上客户端的账号密码 HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(paramsMap, headers); //用refresh_token获取到新的access_token restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor("WebClient","123456")); OAuth2AccessToken token; try{ token = restTemplate.postForObject(GETTOKENURL,entity,OAuth2AccessToken.class); }catch (HttpClientErrorException e){ return new Result(HttpServletResponse.SC_FORBIDDEN,null,e.getMessage()); } return new Result(200,token,"登录成功"); } }
为了真实,还添加了验证码的功能,登录流程是首先访问getVerifyCode获取到验证码和验证码在redis中的key,可以在浏览器或者postman中访问,获取到验证码base64图片上面就是验证码的信息,也可以到redis或者控制台中查看。
第二步,使用postman进行登录,带上账号密码以及验证码和验证码的key一起访问login方法,账号密码是yuki和123456
这样就登录成功了,获取到最重要的访问令牌access_token了,它的过期时间是600秒,正常可以设置得长一点。刷新令牌也要保存下来,防止访问令牌过期,如果不保存,下次就要再次登录了,有了刷新令牌,就可以实现记住我的功能了。
如果访问令牌过期了,就访问refresh方法再次获取访问令牌
这样LoginController的方法就测试完成了,接下来就要实现用户退出的功能了,用户首先要登录后才能退出,因此认证服务器也可以被当作是一个资源服务器,用来校验用户是否登录,只有登录了才能退出。
这里的代码依然在认证服务器中编写
@RestController @RequestMapping("/user") public class UserController { @Autowired private RedisTokenStore redisTokenStore; //获取用户信息接口 @RequestMapping("getUserInfo") public Result getUserInfo(Authentication authentication){ return new Result(200,authentication,"获取用户信息成功"); } //用户退出登录接口 @RequestMapping("/logout") public Result logout(@RequestHeader("authorization") String authorization){ if (!StringUtils.isEmpty(authorization)){ String access_token = authorization.toLowerCase().replace("bearer ", ""); //根据访问令牌获取token信息 OAuth2AccessToken token = redisTokenStore.readAccessToken(access_token); if (token!=null){ //根据token信息删除redis中的数据 redisTokenStore.removeAccessToken(token); OAuth2RefreshToken refreshToken = token.getRefreshToken(); redisTokenStore.removeRefreshToken(refreshToken); } } return new Result(200,null,"退出成功"); } }
在退出之前还先要创建资源服务器的配置文件
/** * 资源服务 */ @Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { @Override public void configure(HttpSecurity http) throws Exception { // 配置放行的资源 http.authorizeRequests() .anyRequest() .authenticated() .and() .requestMatchers() //登录后才能进行访问的资源路径 .antMatchers("/user/**"); } }
在创建两个子项目,分别为student-client和teacher-client,它们添加的代码和刚才认证服务器添加的代码可以是一样的,但还可以再添加多两个类,分别为AccessDeniedHandler权限不足异常类以及AccessDeniedHandler认证失败处理类,不添加也可以,下面以学生资源服务器为例子。
编写完成后的架构
@Component//自定义权限不足异常类 public class CustomAccessDeniedHandler implements AccessDeniedHandler { @Resource private ObjectMapper objectMapper; @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException authException) throws IOException, ServletException { // 返回 JSON response.setContentType("application/json;charset=utf-8"); // 状态码 403 response.setStatus(HttpServletResponse.SC_FORBIDDEN); // 写出 PrintWriter out = response.getWriter(); String errorMessage = authException.getMessage(); if (StringUtils.isBlank(errorMessage)) { errorMessage = "权限不足!"; } Result result = new Result(HttpServletResponse.SC_FORBIDDEN, "权限不足,无法访问资源", errorMessage); out.write(objectMapper.writeValueAsString(result)); out.flush(); out.close(); } }
/** * 认证失败处理 */ @Component public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { @Resource private ObjectMapper objectMapper; @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // 返回 JSON response.setContentType("application/json;charset=utf-8"); // 状态码 401 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 写出 PrintWriter out = response.getWriter(); String errorMessage = authException.getMessage(); if (StringUtils.isBlank(errorMessage)) { errorMessage = "登录失效!"; } Result result = new Result(HttpServletResponse.SC_UNAUTHORIZED, "token无效", errorMessage); out.write(objectMapper.writeValueAsString(result)); out.flush(); out.close(); } }
@Configuration @EnableResourceServer // 标识为资源服务器,请求服务中的资源,就要带着token过来,找不到token或token是无效访问不了资源 @EnableGlobalMethodSecurity(prePostEnabled = true) // 开启方法级别权限控制 public class ResourceConfig extends ResourceServerConfigurerAdapter { @Autowired private CustomAuthenticationEntryPoint myAuthenticationEntryPoint; @Autowired private CustomAccessDeniedHandler customAccessDeniedHandler; @Override public void configure(ResourceServerSecurityConfigurer resources) throws Exception { //因为采取redis存储token,因此要到授权服务器中验证token信息 resources.tokenServices(tokenService()) //当用户传入无效的token会触发myAuthenticationEntryPoint的commence方法进行处理 .authenticationEntryPoint(myAuthenticationEntryPoint) //当用户的权限不足时,customAccessDeniedHandler的handle方法进行处理 .accessDeniedHandler(customAccessDeniedHandler); } @Override public void configure(HttpSecurity http) throws Exception { http .authorizeRequests(request -> { request .antMatchers("/student/**").hasRole("STUDENT") .anyRequest().permitAll(); }) //关闭csrf选项 .csrf().disable() //基于token验证,关闭session .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS); } /** * 配置资源服务器如何验证token有效性 * 1. DefaultTokenServices * 如果认证服务器和资源服务器同一服务时,则直接采用此默认服务验证即可 * 2. RemoteTokenServices (当前采用这个) * 当认证服务器和资源服务器不是同一服务时, 要使用此服务去远程认证服务器验证 */ @Bean public ResourceServerTokenServices tokenService() { // 资源服务器去远程认证服务器验证 token 是否有效 RemoteTokenServices service = new RemoteTokenServices(); // 请求认证服务器验证URL,注意:默认这个端点是拒绝访问的,要设置认证后可访问 service.setCheckTokenEndpointUrl("http://localhost:9050/oauth/check_token"); // 在认证服务器配置的客户端id service.setClientId("WebClient"); // 在认证服务器配置的客户端密码 service.setClientSecret("123456"); return service; } }
@RestController @RequestMapping("/student") public class StudentController { @RequestMapping("/hello") public String auth(){ return "Hello World!"; } @RequestMapping("/auth") public Object auth(Authentication authentication){ return authentication; } @RequestMapping("/auth2") public Object auth2(Authentication authentication){ OAuth2Authentication auth2Authentication = (OAuth2Authentication)authentication; Authentication userAuthentication = auth2Authentication.getUserAuthentication(); return userAuthentication; } @RequestMapping("/auth3") public Object auth3(Authentication authentication){ Object principal = authentication.getPrincipal(); return principal; } }
代码都是一样的,只是控制器和资源服务配置稍微不一样而已
//资源服务配置改变 http .authorizeRequests(request -> { request .antMatchers("/teacher/**").hasRole("TEACHER") .anyRequest().permitAll(); })
TeacherController
@RestController @RequestMapping("/teacher") public class TeacherController { @RequestMapping("/hello") public String auth(){ return "Hello World!"; } // @PreAuthorize("hasAnyRole('TEACHER')") @RequestMapping("/auth") public Object auth(Authentication authentication){ return authentication; } }
因为此时用户的角色是TEACHER,因此可以访问TeacherController中的方法,启动项目进行测试
这样只需要登录一次,获取到redis的token,接下来一直携带这个token就可以访问任意的资源服务器了,从而实现了单点登录的功能。
这就是用springsecurity oauth2+redis实现的单点登录了,微服务架构只要理解思想也是差不多的方法,我会把项目放到百度云,要启动项目只需要开启redis服务,然后修改一下认证服务器的配置文件中的redis配置即可,有需要的来试一下吧。
链接:https://pan.baidu.com/s/1od-WjsrL_BTSJvaDhNpuXg
提取码:leon