SpringSecurity Oauth2 + MybatisPlus + GraphQL + SpringCloud + Redis 骨架
一、涉及技术栈:1. spring security oauth22. GraphQL API3. MybatisPlus 3.1.14. SpringCloud-简版Feign服务间调用5. Redis 保存 token二、介绍:1. 本次将以rbac模型为基础,通过graphql定义好scheme,自定义oauth2...
一、涉及技术栈:
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)