SpringSecurity Oauth2 + MybatisPlus + GraphQL + SpringCloud + Redis 骨架

2 篇文章 0 订阅
1 篇文章 0 订阅

一、涉及技术栈:

    1. spring security oauth2

    2. GraphQL API

    3. MybatisPlus 3.1.1

    4. SpringCloud-简版Feign服务间调用

    5. Redis 保存 token

二、介绍:

     1. 本次将以rbac模型为基础,通过graphql定义好scheme,自定义oauth2登陆接口,通过frontier调用oauth2的登陆接口,并成功返回自定义VO,包括access_token及refresh_token等自定义信息;有关于上面技术栈的定义,请自行查阅API

     2. oauth2简单介绍:

         - 主要角色:认证服务器,资源服务器,资源拥有者;(暂不考虑第三方客户端)

         - 授权模式(本文才用密码模式):

             · 授权码模式:最复杂的一种模式,一般用于第三方登陆,可通过Spring Social实现;

             · 密码模式:一般用于一个产品下有多个子产品,互相信任的情况下;

             · 客户端模式:服务提供者与消费者,通过clientId+clientSecret获取access_token

             · 简化模式(不考虑) 

      3. Graphql:HTTP API ;可与REST对比

      4. MybatisPlus:Mybatis的一种号称无侵入式的实现

      5. SpringCloud Feign:本次没用通过Eureka,直接通过FeignClient的url进行调用

 

三、开搞:

1. 搭建认证服务器(关键代码)

      配置:

         · 自定义UserDetailService实现,验证用户信息,最后交给security验证;

         · 定义加密方式,当前Security若不配置,会出现一个no password:null类似的错误;

         · 声明RestTemplate,用于自定义登陆接口转发到oauth2内部验证;

         · AuthorizationManager开启支持密码模式;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsServiceImpl userDetailsServiceImpl;


    @Bean
    public PasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsServiceImpl);
    }

    @Bean
    GrantedAuthorityDefaults grantedAuthorityDefaults() {
        // Remove the ROLE_ prefix
        return new GrantedAuthorityDefaults("");
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        // 将 check_token 暴露出去,否则资源服务器访问时报 403 错误
        web.ignoring().antMatchers("/oauth/check_token");
    }
}

        · client基于jdbc实现(也可以设置基于内存)

        · 基于Redis存储令牌

        · 设置token过期时间并支持refresh_token

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private RedisConnectionFactory connectionFactory;

    @Autowired
    private DataSource dataSource;

    @Autowired
    private AuthenticationManager authenticationManager;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.withClientDetails(jdbcClientDetails());
    }

    /**
     * 用来配置令牌端点(Token Endpoint)的安全约束.
     *
     * @param security
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        /* 配置token获取合验证时的策略 */
        security.tokenKeyAccess("permitAll()").checkTokenAccess("isAuthenticated()").allowFormAuthenticationForClients();
    }

    @Bean
    public TokenStore tokenStore() {
        // 基于 JDBC 实现,令牌保存到数据
//        return new JdbcTokenStore(dataSource());
        // 基于 Redis 实现,令牌保存到数据
        return new RedisTokenStore(connectionFactory);
    }

    @Bean
    public ClientDetailsService jdbcClientDetails() {
        // 基于 JDBC 实现,需要事先在数据库配置客户端信息
        return new JdbcClientDetailsService(dataSource);
    }

    @Primary
    @Bean
    public AuthorizationServerTokenServices tokenServices() {
        DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
        defaultTokenServices.setAccessTokenValiditySeconds(60000);
        defaultTokenServices.setRefreshTokenValiditySeconds(604800);
        defaultTokenServices.setSupportRefreshToken(true);
        defaultTokenServices.setReuseRefreshToken(false);
        defaultTokenServices.setTokenStore(tokenStore());
        return defaultTokenServices;
    }

    /**
     * 用来配置授权(authorization)以及令牌(token)的访问端点和令牌服务(token services)
     *
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        // 配置tokenStore
        endpoints.tokenStore(tokenStore());
        endpoints.tokenServices(tokenServices());
        endpoints.authenticationManager(authenticationManager);
    }

    @Bean
    public RedisTokenStore redisTokenStore() {
        return new RedisTokenStore(connectionFactory);
    }
}

2. 这里既把authorization当做认证服务器同时也当做资源服务器,因为token在Feign中并不会自动装载到Header携带,所以需要手动实现一个拦截,装载到到Header中

 

@Configuration
public class FeignOauth2RequestInterceptor implements RequestInterceptor {

    private final String AUTHORIZATION_HEADER = "Authorization";
    private final String BEARER_TOKEN_TYPE = "Bearer";

    @Override
    public void apply(RequestTemplate requestTemplate) {
        SecurityContext securityContext = SecurityContextHolder.getContext();
        Authentication authentication = securityContext.getAuthentication();
        if (authentication != null && authentication.getDetails() instanceof OAuth2AuthenticationDetails) {
            OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails();
            requestTemplate.header(AUTHORIZATION_HEADER, String.format("%s %s", BEARER_TOKEN_TYPE, details.getTokenValue()));
        }
    }
}

     - 既然也是资源服务器,就要配置它为资源服务器;

        · 放行login方法;在此亦可配置其他参数,比如自定义异常;

@Configuration
@EnableResourceServer
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {


    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers("/oauth/login/user").permitAll()
                .anyRequest().authenticated()
                .and().logout().permitAll();
    }
}

3. 实现自定义login()

        · 启动类(若不加@EnableFeignClients会报错重复注入;若某项目既是提供者又是消费者,则需要指定被调用者api路径)

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.transaction.annotation.EnableTransactionManagement;

/**
 * @author xxx
 * @date 2019/5/24 9:51
 */
@SpringBootApplication(scanBasePackages = "com.xxx.auth")
@MapperScan(basePackages = "com.xxx.auth.server.dao")
@EnableTransactionManagement
@EnableFeignClients
public class AuthorizationApplication {
    public static void main(String[] args) {
        SpringApplication.run(AuthorizationApplication.class);
    }
}

        · 通过api暴露出接口

@FeignClient(url = "localhost:8080",name = "auth-server",configuration = FeignClientsConfiguration.class)
@RequestMapping(value = "/")
public interface IOauthLoginController {

    /**
     * @param loginUserDTO
     * @return LoginUserVO
     * @author xxx
     * @description 登陆
     * @date 10:35 2019/5/20
     **/
    @PostMapping(value = "/oauth/login/user")
    LoginUserVO loginUser(@RequestBody LoginUserDTO loginUserDTO);
}

        · 获取参数-->通过restTemplate.exchange()调用oauth2内部方法,获取token信息-->在redis中寻找token,若存在,则刷新token,保证token有效(access_token一般设置时间较短,有些企业设置10分钟,refresh_token可设置时间长久一些,比如七天等)-->通过response添加到cookie中,返回前端

public static final String[] GRANT_TYPE = {"password", "refresh_token"};   
     /**
     * @param loginUserDTO
     * @return LoginUserVO
     * @author xxx
     * @description 登陆
     * @date 10:35 2019/5/20
     **/
    @Override
    public LoginUserVO login(@RequestBody LoginUserDTO loginUserDTO) {

        if (Objects.isNull(loginUserDTO.getUsername()) || Objects.isNull(loginUserDTO.getPassword())) {
            throw new ServiceException(ErrorCodeEnum.BIZ_PARAM_ERR);
        }

        if (Objects.isNull(loginUserDTO.getClientId()) || Objects.isNull(loginUserDTO.getClientSecret())) {
            throw new ServiceException(ErrorCodeEnum.TOKEN_NULL);
        }

        MultiValueMap<String, Object> paramMap = new LinkedMultiValueMap<>();
        paramMap.add("client_id", loginUserDTO.getClientId());
        paramMap.add("client_secret", loginUserDTO.getClientSecret());
        paramMap.add("username", loginUserDTO.getUsername());
        paramMap.add("password", loginUserDTO.getPassword());
        paramMap.add("grant_type", GRANT_TYPE[0]);
        Token token = new Token();
        try {
            //因为oauth2本身自带的登录接口是"/oauth/token",并且返回的数据类型不能按我们想要的去返回
            //所以这里用restTemplate(HTTP客户端)进行一次转发到oauth2内部的登录接口
            tokenHandler(paramMap, token);
            LoginUserVO loginUserVO = redisUtil.get(token.getAccessToken(), LoginUserVO.class);
            if (loginUserVO != null) {
                //登录的时候,判断该用户是否已经登录过了
                //如果redis里面已经存在该用户已经登录过了的信息
                token = oauthRefreshToken(loginUserDTO.getClientId(), loginUserDTO.getClientSecret(), loginUserVO.getRefreshToken());
                redisUtil.deleteCache(loginUserVO.getAccessToken());
            }
        } catch (RestClientException | ClassNotFoundException | NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
            try {
                e.printStackTrace();
                throw new ServiceException(ErrorCodeEnum.BIZ_CODE_ERROR);
            } catch (Exception e1) {
                e1.printStackTrace();
            }
        }
        //这里我拿到了登录成功后返回的token信息之后,再进行一层封装,最后返回给前端的其实是LoginUserVO
        LoginUserVO loginUserVO = new LoginUserVO();
        LambdaQueryWrapper<OauthUser> wrapper = new QueryWrapper<OauthUser>().lambda()
                .eq(OauthUser::getUsername, loginUserDTO.getUsername());
        OauthUser userPO = getOne(wrapper);
        BeanUtils.copyPropertiesIgnoreNull(userPO, loginUserVO);
        loginUserVO.setId(userPO.getId().intValue());
        loginUserVO.setAccount(userPO.getUsername());
        loginUserVO.setPassword(userPO.getPassword());
        loginUserVO.setAccessToken(token.getAccessToken());
        loginUserVO.setAccessTokenExpiresIn(token.getExpiresIn());
        loginUserVO.setTokenType(token.getTokenType());
        loginUserVO.setRefreshToken(token.getRefreshToken());
        //存储登录的用户
        redisUtil.set(loginUserVO.getAccessToken(), loginUserVO, TimeUnit.MINUTES.toSeconds(1));
        httpServletResponse.addCookie(new Cookie("access_token", loginUserVO.getAccessToken()));
        httpServletResponse.addCookie(new Cookie("refresh_token", loginUserVO.getRefreshToken()));
        return loginUserVO;
    }

    /**
     * @param clientId
     * @param clientSecret
     * @param refreshToken
     * @return
     * @description oauth2客户端刷新token
     * @date 2019/05/20 14:27:22
     * @author xxx
     */
    private Token oauthRefreshToken(String clientId, String clientSecret, String refreshToken) {
        MultiValueMap<String, Object> paramMap = new LinkedMultiValueMap<>();
        paramMap.add("client_id", clientId);
        paramMap.add("client_secret", clientSecret);
        paramMap.add("refresh_token", refreshToken);
        paramMap.add("grant_type", GRANT_TYPE[1]);
        Token token = new Token();
        try {
            tokenHandler(paramMap, token);
        } catch (RestClientException | ClassNotFoundException | NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
            try {
                throw new ServiceException(ErrorCodeEnum.FAIL);
            } catch (Exception e1) {
                e1.printStackTrace();
            }
        }
        return token;
    }

    /**
     * @param paramMap, token
     * @return void
     * @author xxx
     * @description 转发到oauth2 内部校验接口
     * @date 13:48 2019/5/21
     **/
    private void tokenHandler(MultiValueMap<String, Object> paramMap, Token token) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException {
        HttpHeaders requestHeaders = new HttpHeaders();
        requestHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        HttpEntity<MultiValueMap<String, Object>> httpEntity = new HttpEntity<>(paramMap, requestHeaders);
        ResponseEntity<String> responseEntity = restTemplate.exchange(serverConfig.getUrl() + UrlEnum.LOGIN_URL.getUrl(), HttpMethod.POST, httpEntity, String.class);
        String body = responseEntity.getBody();
        if (Objects.isNull(body)) {
            throw new ServiceException(ErrorCodeEnum.BIZ_CODE_ERROR);
        }
        String tk = body.replace("{", "").replace("}", "");
        String[] tokenMapStr = tk.split(",");
        Map<String, Object> tokenMap = new HashMap<>();
        for (String s : tokenMapStr) {
            System.err.println("s:" + s);
            String quotation = s.replaceAll("\"", "");
            String[] ms = quotation.split(":");
            tokenMap.put(ms[0], ms[1]);
        }
        DataHelper.putDataIntoEntity(tokenMap, token);
        log.info("token:{}", token);
    }

4. 集成GraplQL

    - 此时已经可以通过postman调用测试了,一定要记得将路径放行,否则会报错需要全部权限

    - 新起项目:auth-frontier

application.yml

auth-server-url: http://localhost:8080

spring:
  application:
    name: auth-frontier

server:
  port: 8081

graphql:
  servlet:
    mapping: /graphql
    enabled: true
    corsEnabled: true

logging:
  level:
    "org.springframework": info

security:
  oauth2:
    client:
      client-id: testclientid
      client-secret: 123456
      scope: read_userinfo
      access-token-uri: ${auth-server-url}/oauth/token
      user-authorization-uri: ${auth-server-url}/oauth/authorize
    resource:
      token-info-uri: ${auth-server-url}/oauth/check_token

---------------------------------------------------------------
pom.xml
<!-- graphql -->
        <dependency>
            <groupId>com.xxx.auth</groupId>
            <artifactId>auth-api</artifactId>
            <version>1.0.0-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>com.graphql-java-kickstart</groupId>
            <artifactId>graphql-spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>com.graphql-java-kickstart</groupId>
            <artifactId>graphiql-spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>com.graphql-java-kickstart</groupId>
            <artifactId>voyager-spring-boot-starter</artifactId>
        </dependency>
        <!--feign-->
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>
        <dependency>
            <groupId>io.github.openfeign</groupId>
            <artifactId>feign-httpclient</artifactId>
        </dependency>

         · 老规矩定义资源服务器并放行/graphql  (主启动类记得加@EnableFeignClients注解)

/**
 * @author xxx
 * @date 2019/05/23
 **/
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {


    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers("/graphql").permitAll()
                .anyRequest().authenticated()
                .and().logout().permitAll();
    }

    @Bean
    public RestTemplate restTemplate() {
        RestTemplate restTemplate = new RestTemplate();
        MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter = new MappingJackson2HttpMessageConverter();
        mappingJackson2HttpMessageConverter.setSupportedMediaTypes(Arrays.asList(MediaType.APPLICATION_JSON, MediaType.APPLICATION_OCTET_STREAM));
        restTemplate.getMessageConverters().add(mappingJackson2HttpMessageConverter);
        return restTemplate;
    }

   - 定义LoginResolver

/**
 * @author xxx
 * @date 2019/5/23 18:25
 */
@Service
public class LoginResolver implements GraphQLMutationResolver {

    @Value("${security.oauth2.client.client-id}")
    private String clientId;

    @Value("${security.oauth2.client.client-secret}")
    private String clientSecret;

    private IDSLoginController dsLoginController;

    @Autowired
    public LoginResolver(IDSLoginController dsLoginController) {
        this.dsLoginController = dsLoginController;
    }

    public LoginUserVO DsLogin(String username , String password) {
        LoginUserDTO loginUserDTO = new LoginUserDTO().setUsername(username)
                .setPassword(password)
                .setClientId(clientId)
                .setClientSecret(clientSecret);
        return dsLoginController.login(loginUserDTO);
    }


}

     - 定义login.graphqls

type Mutation {
    DsLogin(username:String!,password:String!):LoginUserVO
}

type LoginUserVO{

    ## 用户Id ##
    id: Int

    ##用户账号
    account : String

    ## 用户名 ##
    name : String

    ## 用户密码 ##
    password : String

    ## accessToken码 ##
    accessToken : String

    ## accessToken过期时限 ##
    accessTokenExpiresIn : String

    ## token类型 ##
    tokenType : String

    ## refreshToken码 ##
    refreshToken : String

}

搞定!启动看结果

再次启动,相当于代码中的刷新token,来看结果

token值已改变,示范完成;两个服务与多个服务调用顺序是相同的,唯一要注意的就是@EableFeignClients的扫包范围;

若有疑问,欢迎留言共同讨论问题!

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值