SpringCloud-Gateway网关统一登录鉴权+QQ第三方登录+Vue前后分离解决方案
网关Gateway服务pom.xmlspringboot 2.3.3版本<!--注册中心客户端--><dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId&
具体流程
* 网关鉴权流程:
* 前端输入用户名密码去请求token,经过SecurityWeb配置,
* 白名单不进入AuthorizationManager,直接进全局过滤器->没有token放行
* 由网关转发/auth/** 到ouath2服务请求token
*
* ouath2服务:
* 请求token是白名单放行,通过SecurityUserDetails体系验证用户,发放token...
* 前端拿到token,带token去请求用户信息
*
* 网关拦截非白名单请求进入AuthorizationManager,无token直接拦截
* 有token 通过框架spring-security-oauth2-resource-server去请求ouath2服务获取rsa公钥解析token
* 再检验角色权限roleId,去redis或数据库拿到id列表,与token中的权限匹配
* token解析成功 权限验证成功则进入全局过滤器,否则进入失败处理器
* 进入全局过滤器验证一下token过期, 刷新, 修改密码(对比jti),然后再放行
* 再经网关转发到具体服务
*
* 第三方(qq)鉴权流程
* 网关->qq登录请求是白名单,进入ouath2服务通过qqSDK请求qq登录的html
* 前端弹出登陆qq页,扫码登陆qq,qq登录成功会回调我们ouath2服务的异步回调接口(要设白名单,通过域名访问是经过nginx)并带code码
* 然后我们凭code码可以拿到qq头像,qq网名,然后我们也用qq头像做用户头像,qq的OpenId记录,经过自定义的token组装,生成token返回给前端
* 这里:
* 我们自己的登录体系是密码模式
* qq第三方那边就是授权码模式了
* 有优化的思路可以提出来一起分享,谢谢~
网关Gateway服务
- pom.xml
springboot 2.3.3版本
<!--注册中心客户端-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--网关-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!--Spring-Gateway使用webflux-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!--Gateway-Oauth2鉴权-->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<!--redis + 连接池-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
- 跨域配置文件
/**
* Gateway网关全局跨域
*/
@Configuration
public class Config_GatewayCors {
@Bean
public CorsWebFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true); // 允许cookies跨域
config.addAllowedOrigin("*"); // 允许向该服务器提交请求的URI,*表示全部允许,在SpringMVC中,如果设成*,会自动转成当前请求头中的Origin
config.addAllowedHeader("*"); // 允许访问的头信息,*表示全部
config.addAllowedMethod("*"); // 允许提交请求的方法类型,*表示全部允许
config.setMaxAge(18000L); // 预检请求的缓存时间-秒,即在这个时间段里,对于相同的跨域请求不会再预检了
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser());
source.registerCorsConfiguration("/**", config);
return new CorsWebFilter(source);
}
}
- web安全配置
/**
* WebSecurity配置
* 网关服务当做资源总服务
*/
@Slf4j
@Configuration
@EnableWebFluxSecurity
public class Config_Security {
@Resource
private AuthorizationManager authorizationManager;
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
log.info("1");
http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(jwtAuthenticationConverter());
//自定义Token过期或签名错误的结果
http.oauth2ResourceServer().authenticationEntryPoint(new SecurityAuthenticationEntryPoint());
//对白名单路径直接移除Token
http.addFilterBefore(new IgnoreUrlsRemoveJwtFilter(), SecurityWebFiltersOrder.AUTHENTICATION);
//配置白名单和访问规则,CommonEnum枚举类
http.csrf().disable().authorizeExchange()
.pathMatchers(CommonEnum.urls.toArray(new String[]{})).permitAll()
.anyExchange().access(authorizationManager)
.and().exceptionHandling()
.accessDeniedHandler(new SecurityAccessDeniedHandler());
return http.build();
}
/**
* ServerHttpSecurity没有将jwt中authorities的负载部分当做Authentication,需要把jwt的Claim中的authorities加入
* 定义ReactiveAuthenticationManager权限管理器,默认转换器JwtGrantedAuthoritiesConverter
*/
@Bean
public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
jwtGrantedAuthoritiesConverter.setAuthorityPrefix(CommonEnum.AUTHORITY_PREFIX);
jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(CommonEnum.AUTHORITY_CLAIM_NAME);
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
}
/**
* token认证失败处理器
*/
static class SecurityAuthenticationEntryPoint implements ServerAuthenticationEntryPoint {
@Override
public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException e) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.OK);
response.getHeaders().add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
CommonResponse<?> responseCon = new CommonResponse<>(401,"身份认证失败", e.getMessage());
String body= JSON.toJSONString(responseCon);
DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
}
}
/**
* 权限不足处理器
*/
static class SecurityAccessDeniedHandler implements ServerAccessDeniedHandler {
@Override
public Mono<Void> handle(ServerWebExchange exchange, AccessDeniedException denied) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.OK);
response.getHeaders().add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
CommonResponse<?> responseCon = new CommonResponse<>(401, "没有权限", denied.getMessage());
String body= JSON.toJSONString(responseCon);
DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
}
}
/**
* 白名单路径访问时需要移除token请求头
*/
static class IgnoreUrlsRemoveJwtFilter implements WebFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
URI uri = request.getURI();
PathMatcher pathMatcher = new AntPathMatcher();
for (String ignoreUrl: CommonEnum.urls) {
if (pathMatcher.match(ignoreUrl, uri.getPath())) {
request = exchange.getRequest().mutate().header("Authorization", "").build();
exchange = exchange.mutate().request(request).build();
return chain.filter(exchange);
}
}
return chain.filter(exchange);
}
}
}
- redis配置
/**
* redis配置
*/
@Configuration
public class Config_Redis {
/**
* 序列化防止存到redis中乱码, ConditionalOnMissingBean必须定义这个名字,不然springboot会有默认的
*/
@Bean
@ConditionalOnMissingBean(name = "redisTemplate")
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringRedisSerializer);
redisTemplate.setHashKeySerializer(stringRedisSerializer);
Jackson2JsonRedisSerializer<?> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}
- 一些字段
/**
* 通用枚举类
*/
public class CommonEnum {
//放行白名单
public final static List<String> urls =
Arrays.asList(
"/cloud-oauth2/QQLogin", //请求QQ登录放行
"/cloud-oauth2/qq_notify_url", //请求QQ登录异步回调放行
"/GetVerifyCode", //请求验证码放行
"/rsa/publicKey", //请求公钥放行
"/actuator/**", //请求oauth2放行
"/auth/oauth/token", //请求oauth2放行
"/shop-member/Register", //请求用户注册放行
"/**/GetHomeProduct", //请求商城首页数据放行
"/**/SearchGoods", //请求搜索商品放行
"/**/ali_return_url", //支付宝同步回调放行
"/**/ali_notify_url" //支付宝异步回调放行
);
//redis权限列表key
public final static String RESOURCE_ROLES_MAP = "AUTH:RESOURCE_ROLES_MAP";
//权限的前置部分
public final static String AUTHORITY_PREFIX = "ROLE_";
public final static String AUTHORITY_CLAIM_NAME = "authorities";
//token标识
public final static String TOKEN_HEADER = "Authorization";
//发往其他服务的请求头标识
public final static String USER_HEADER = "USER_HEADER";
}
- 全局过滤器
/**
* 全局过滤器
*/
@Slf4j
@Component
public class AuthGlobalFilter implements GlobalFilter {
@Resource
private RedisTemplate<String, Object> redisTemplate;
//白名单的请求会经过过滤器
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
log.info("全局过滤器:{}",exchange.getRequest().getPath());
String token = exchange.getRequest().getHeaders().getFirst(CommonEnum.TOKEN_HEADER);
if (token == null || token.equals("")) {
return chain.filter(exchange); //白名单如果没有token, 直接放行
}
try {
//从Token中解析用户信息并设置到Header中去
JWSObject jwsObject = JWSObject.parse(token.replace("Bearer ", ""));
String userStr = jwsObject.getPayload().toString();
//token(过期, 刷新, 修改密码)校验
Map<String, Object> maps = (Map) JSON.parse(userStr);
//请求token中的jti
String oldJti = (String) maps.get("jti");
//创建新token时存到redis的jti
String newJti = (String) redisTemplate.opsForValue().get(maps.get("id") + (String) maps.get("user_name"));
//校验jti字段判断token是否失效
if (!oldJti.equals(newJti)) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.OK);
response.getHeaders().add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
CommonResponse<?> responseCon = new CommonResponse<>(401, "error", "Token令牌失效");
String body = JSON.toJSONString(responseCon);
DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(buffer));
}
//没有失效,向headers中放数据,后续服务就不需要解析JWT令牌,可以直接从请求的Header中获取到用户信息
ServerHttpRequest request = exchange.getRequest().mutate().header(CommonEnum.USER_HEADER, userStr).build();
exchange = exchange.mutate().request(request).build();
} catch (Exception e) {
log.error("过滤器发生错误:{}", e.getMessage());
}
//如果没有token
return chain.filter(exchange);
}
}
- 鉴权管理器
/**
* 鉴权管理器
*/
@Slf4j
@Component
public class AuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
@Resource
private RedisTemplate<String,Object> redisTemplate;
/**
* 不是白名单请求经过这里,先校验可访问角色列表
*/
@Override
public Mono<AuthorizationDecision> check(Mono<Authentication> mono, AuthorizationContext authorizationContext) {
log.info("2");
ServerHttpRequest request = authorizationContext.getExchange().getRequest();
String path = request.getURI().getPath();
if (request.getMethod() == HttpMethod.OPTIONS) {
log.info("对跨域的预检请求直接放行,路径为:{}",path);
return Mono.just(new AuthorizationDecision(true));
}
String token = request.getHeaders().getFirst(CommonEnum.TOKEN_HEADER);
log.info(path+"路径,token:{}",token);
if(token==null || token.equals("")){
//token为空不放行
return Mono.just(new AuthorizationDecision(false));
}
//从redis中取出权限列表
Object obj = redisTemplate.opsForHash().get(CommonEnum.RESOURCE_ROLES_MAP, path);
List<String> authorities = new ArrayList<>();
if (obj instanceof ArrayList<?>) {
for (Object o : (List<?>) obj) {
authorities.add(CommonEnum.AUTHORITY_PREFIX + o);
}
}
return mono
.filter(Authentication::isAuthenticated)
.flatMapIterable(Authentication::getAuthorities)
.map(GrantedAuthority::getAuthority)
.any(roleId -> true) //我这边不做鉴权 就返回true了,需要鉴权就写 authorities.contains(roleId)
.map(AuthorizationDecision::new)
.defaultIfEmpty(new AuthorizationDecision(false));
}
}
- 网关使用bootstarp.yml
server:
port: 8080
spring:
main:
allow-bean-definition-overriding: true #当遇到同样名字的时候,是否允许覆盖注册
application:
name: cloud-gateway
cloud:
nacos:
discovery:
server-addr: ip:8848
config:
server-addr: ip:8848 #NaCos配置中心地址
file-extension: yaml
username: nacos
password: nacos
gateway:
discovery:
locator:
enabled: true #是否与服务发现组件进行结合,通过serviceId转发到具体的服务实例
lower-case-service-id: true #使用小写service-id
routes:
- id: route1
uri: lb://cloud-oauth2
predicates:
- Path=/auth/**
filters:
- StripPrefix=1 #截取掉auth,当请求是白名单login,获取token,会经过这里转发到验证服务
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://cloud-oauth2:9988/rsa/publicKey #获取公钥路径,docker-compose下才能使用cloud-oauth2域名,否则使用ip(需要查看验证服务器的docker容器ip)
#redis
redis:
host: redis
port: 6379
lettuce:
pool:
max-idle: 100 #最大空闲连接数
min-idle: 20 #最小空闲连接数
max-wait: -1s #等待可用连接的最大时间,负数为不限制
max-active: -1 #最大活跃连接数,负数为不限制
password: xxxxxx
#指定日志文件
logging:
config: classpath:logback-logstash.xml
oauth2验证服务,集成QQ登录
- pom.xml
<!--oauth2 + JWT工具-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.1.3</version>
</dependency>
<!--QQ登录依赖-->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.9</version>
</dependency>
<dependency>
<groupId>net.gplatform</groupId>
<artifactId>Sdk4J</artifactId>
<version>2.0</version>
</dependency>
...redis和mysql略
- application.yml
server:
port: 9988
spring:
profiles:
include: com-nacos,com-redis,com-sql,com-openfeign
application:
name: cloud-oauth2
myOauth2:
clientId: admin1
clientSecret: 123456
qq:
oauth:
appid: xxx
appkey: xxx
http: http://www.lyhosiris.cn/
callback_url: http://www.lyhosiris.cn/api/cloud-oauth2/qq_notify_url #QQ互联中填写的回调地址
- QQ请求工具
public class QQHttpClient {
private static JSONObject parseJSONP(String jsonp){
int startIndex = jsonp.indexOf("(");
int endIndex = jsonp.lastIndexOf(")");
String json = jsonp.substring(startIndex + 1,endIndex);
return JSONObject.parseObject(json);
}
//qq返回token信息 access_token=xxx&expires_in=xxx&refresh_token=xxx
public static String getAccessToken(String url) throws IOException {
CloseableHttpClient client = HttpClients.createDefault();
String token = null;
HttpGet httpGet = new HttpGet(url);
HttpResponse response = client.execute(httpGet);
HttpEntity entity = response.getEntity();
if(entity != null){
String result = EntityUtils.toString(entity,"UTF-8");
if(result.contains("access_token")){
String[] array = result.split("&");
for (String str : array){
if(str.contains("access_token")){
token = str.substring(str.indexOf("=") + 1);
break;
}
}
}
}
httpGet.releaseConnection();
return token;
}
//qq返回openid信息 callback( {"client_id":"YOUR_APPID","openid":"YOUR_OPENID"} ); 需要用到上面自己定义的解析方法parseJSONP
public static String getOpenID(String url) throws IOException {
JSONObject jsonObject = null;
CloseableHttpClient client = HttpClients.createDefault();
HttpGet httpGet = new HttpGet(url);
HttpResponse response = client.execute(httpGet);
HttpEntity entity = response.getEntity();
if(entity != null){
String result = EntityUtils.toString(entity,"UTF-8");
jsonObject = parseJSONP(result);
}
httpGet.releaseConnection();
if(jsonObject != null){
return jsonObject.getString("openid");
}else {
return null;
}
}
//qq返回用户信息 { "ret":0, "msg":"", "nickname":"YOUR_NICK_NAME", ... },为JSON格式,直接使用JSONObject对象解析
public static JSONObject getUserInfo(String url) throws IOException {
JSONObject jsonObject = null;
CloseableHttpClient client = HttpClients.createDefault();
HttpGet httpGet = new HttpGet(url);
HttpResponse response = client.execute(httpGet);
HttpEntity entity = response.getEntity();
if(entity != null){
String result = EntityUtils.toString(entity,"UTF-8");
jsonObject = JSONObject.parseObject(result);
}
httpGet.releaseConnection();
return jsonObject;
}
}
- 通用枚举类
public class CommonEnum {
//白名单
public final static List<String> urls =
Arrays.asList(
"/QQLogin", //请求QQ登录放行
"/qq_notify_url", //请求QQ登录异步回调放行
"/rsa/publicKey" //请求公钥放行
);
public final static String RESOURCE_ROLES_MAP = "AUTH:RESOURCE_ROLES_MAP";
public final static String AUTHORITY_PREFIX = "ROLE_";
public final static String AUTHORITY_CLAIM_NAME = "authorities";
public final static String TOKEN_HEADER = "Authorization";
public final static String USER_HEADER = "USER_HEADER";
}
- web安全配置类
/**
* Security配置类
* EnableWebSecurity开启Security
*/
@Slf4j
@Configuration
@EnableWebSecurity
public class Config_WebSecurity extends WebSecurityConfigurerAdapter {
/**
* 用来配置拦截保护的请求,这里配置所有请求都需要认证
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers(CommonEnum.urls.toArray(new String[]{})).permitAll() //白名单放行
.anyRequest().authenticated() //所有请求都需要通过认证
.and().httpBasic() //Basic提交
.and().csrf().disable()
.formLogin().permitAll(); //支持表单认证
}
/**
* 配置验证管理器
*/
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
- 配置验证服务类
/**
* 验证服务器, EnableAuthorizationServer注解表示是个验证服务器
*/
@Slf4j
@Configuration
@EnableAuthorizationServer
public class Config_Authorization extends AuthorizationServerConfigurerAdapter {
@Value("${myOauth2.clientId}")
private String clientId;
@Value("${myOauth2.clientSecret}")
private String clientSecret;
@Resource
private AuthenticationManager authenticationManager; //注入安全管理器
@Resource
private MyPasswordEncoder myPasswordEncoder; //注入我们的密码工具类
@Resource
private UserServiceImpl userDetailsService; //注入用户包装实现类
// //============redis存储token===============
// @Resource
// private RedisConnectionFactory redisConnectionFactory;
//=============JWT存储token==================
@Resource
private TokenStore tokenStore;
@Resource
private JwtAccessTokenConverter accessTokenConverter;
@Resource
private Jwt_TokenEnhancer jwtTokenEnhancer;
/**
* 访问端点配置,配置授权authorization以及令牌(token)的访问端点和令牌服务(token services)
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
//配置Redis存储token
//endpoints.tokenStore(new RedisTokenStore(redisConnectionFactory));
//配置jwt存储token + jwt自定义内容增强
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(jwtTokenEnhancer, accessTokenConverter));
endpoints.tokenStore(tokenStore).accessTokenConverter(accessTokenConverter).tokenEnhancer(tokenEnhancerChain);
//配置管理器允许GET和POST请求获取token;即访问端点oauth/token
endpoints.authenticationManager(authenticationManager).allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST);
//要使用refresh_token的话,需要额外配置userDetailsService
endpoints.userDetailsService(userDetailsService);
}
/**
* 授权端点开放,配置令牌端点(Token Endpoint)的安全约束,也就是这个端点谁能访问,谁不能访问
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
oauthServer
.tokenKeyAccess("permitAll()") //开启/oauth/token_key验证端口无权限访问
.checkTokenAccess("isAuthenticated()") //开启/oauth/check_token验证端口认证权限访问
.allowFormAuthenticationForClients(); //允许表单认证
}
/**
* 配置客户端详情服务,客户端详情信息在这里进行初始化,通过数据库来存储调取详情信息
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
//使用内存模式,也可以配置客户端存储到数据库DB,你能够把客户端详情信息写死在这里或者是通过数据库来存储调取详情信息
clients.inMemory()
.withClient(clientId) //client_id
.secret(myPasswordEncoder.encode(clientSecret)) //client_密码
.accessTokenValiditySeconds(3600) //配置刷新token的有效期
.refreshTokenValiditySeconds(864000) //配置刷新token的有效期
.scopes("all")
.authorizedGrantTypes("password", "refresh_token"); //授权类型:密码模式
}
}
- jwt 存储配置类
/**
* 使用Jwt存储token的配置
*/
@Configuration
public class Jwt_TokenStore {
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setKeyPair(keyPair());
return converter;
}
@Bean
public Jwt_TokenEnhancer jwtTokenEnhancer() {
return new Jwt_TokenEnhancer();
}
/**
* 非对称加密rsa 需要证书文件
* resource中的jwt.jks文件是使用keytool生成RSA证书jwt.jks
* jdk安装目录bin中,cmd
* 命令:keytool -genkey -alias jwt -keyalg RSA -keystore jwt.jks
* 输入你的密码,城市等等信息生成证书jwt.jks文件
*
* 生成成功后在jdk安装目录bin中
* 复制到resource目录下
*/
@Bean
public KeyPair keyPair() {
//从classpath下的证书中获取秘钥对,123456是密码
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "123456".toCharArray());
return keyStoreKeyFactory.getKeyPair("jwt", "123456".toCharArray());
}
}
- 配置jwt内容增强器
/**
* Jwt内容增强器
*/
@Slf4j
public class Jwt_TokenEnhancer implements TokenEnhancer {
@Resource
private RedisTemplate<String,Object> redisTemplate;
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
SecurityUserDetails securityUser = (SecurityUserDetails) authentication.getPrincipal();
log.info("用户信息{}",securityUser.toString());
//log.info("权限列表{}",authentication.getAuthorities());
Map<String, Object> info = new HashMap<>();
//把用户信息设置到jwt载荷与redis中
info.put("id", securityUser.getId());
try{
redisTemplate.opsForValue().set(securityUser.getId()+securityUser.getUsername(),accessToken.getValue());
}catch (Exception e){
e.printStackTrace();
}
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(info);
return accessToken;
}
}
还有就是redis的配置文件,用网关中的
- Security用户管理业务类
/**
* Security用户管理业务类
*/
@Service
public class UserServiceImpl implements UserDetailsService {
@Resource
private IUmsMemberService umsMemberService;
//根据用户名查数据库
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UmsMember umsMember = umsMemberService.GetByNameMember(username);
if(umsMember==null) {
throw new UsernameNotFoundException("用户名或密码错误");
}
SecurityUserDetails details = new SecurityUserDetails(umsMember,Arrays.asList("USER"));
if (!details.isEnabled()) {
throw new DisabledException("用户未启用");
} else if (!details.isAccountNonLocked()) {
throw new LockedException("用户被锁定");
} else if (!details.isAccountNonExpired()) {
throw new AccountExpiredException("用户已过期");
} else if (!details.isCredentialsNonExpired()) {
throw new CredentialsExpiredException("密码已过期");
}
return details;
}
}
- 生成token服务类
interface MyTokenService {
String createMyToken(Long id, String openId, Map<String, String> parameters, MyPasswordEncoder myPasswordEncoder) ;
}
@Slf4j
@Service
public class MyTokenServiceImpl implements MyTokenService {
@Value("${myOauth2.clientId}")
private String clientId;
@Value("${myOauth2.clientSecret}")
private String clientSecret;
@Resource
private TokenStore tokenStore;
@Resource
private JwtAccessTokenConverter accessTokenConverter;
@Resource
private Jwt_TokenEnhancer jwtTokenEnhancer;
@Resource
private ClientDetailsService clientDetailsService;
@Override
public String createMyToken(Long id, String openId, Map<String, String> parameters, MyPasswordEncoder passwordEncoder) {
parameters.put("client_id", clientId);
parameters.put("client_secret", clientSecret);
parameters.put("grant_type", "password");
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setSupportRefreshToken(true);
defaultTokenServices.setTokenStore(tokenStore);
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(jwtTokenEnhancer, accessTokenConverter));
defaultTokenServices.setTokenEnhancer(tokenEnhancerChain);
ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId);
TokenRequest tokenRequest = new TokenRequest(parameters, clientId, Collections.singleton("all"),"password");
OAuth2Request oAuth2Request = tokenRequest.createOAuth2Request(clientDetails);
//构造权限列表
GrantedAuthority grantedAuthority = new GrantedAuthority() {
@Override
public String getAuthority() {
return "USER";
}
};
//初始密码abc123
SecurityUserDetails userDetails = new SecurityUserDetails(id, openId, passwordEncoder.encode("abc123"),Arrays.asList("USER"));
//创建UsernamePasswordAuthenticationToken
Authentication userAuth = new UsernamePasswordAuthenticationToken(userDetails, "[PROTECTED]", Arrays.asList(grantedAuthority));
//将OAuth2Request 和 Authorization 两个对象组合起来形成一个 OAuth2Authorization 对象
OAuth2Authentication oAuth2Authentication = new OAuth2Authentication(oAuth2Request, userAuth);
//OAuth2Authentication对象会传递到AuthorizationServerTokenServices的实现类DefaultTokenServices中;最终会生成一个OAuth2AccessToken
OAuth2AccessToken accessToken = defaultTokenServices.createAccessToken(oAuth2Authentication);
//返回我们的生成的token
return accessToken.getValue();
}
}
- 自定义密码加密类
@Component
public class MyPasswordEncoder implements PasswordEncoder {
private final String salt = "yan";
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return encodedPassword.equals(encode(rawPassword));
}
@Override
public String encode(CharSequence rawPassword) {
String base = rawPassword + salt;
return DigestUtils.md5DigestAsHex(base.getBytes());
}
}
- 登录的controller
@Slf4j
@RestController
public class QQLoginController {
@Value("${qq.oauth.callback_url}")
private String callback_url;
@Value("${qq.oauth.appid}")
private String APPID;
@Value("${qq.oauth.appkey}")
private String APPKEY;
@Resource
private MyTokenService myTokenService;
@Resource
private IUmsMemberService umsMemberService;
@Resource
private MyPasswordEncoder myPasswordEncoder;
@Resource
private KeyPair keyPair;
//获取RSA公钥接口
@RequestMapping("/rsa/publicKey")
public Map<String, Object> getKey() {
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAKey key = new RSAKey.Builder(publicKey).build();
return new JWKSet(key).toJSONObject();
}
/**
* 发起QQ登录请求
*/
@RequestMapping("/QQLogin")
public CommonResponse<String> QQLogin(HttpSession session) {
//用于第三方应用防止CSRF攻击
String uuid = UUID.randomUUID().toString().replaceAll("-", "");
session.setAttribute("state", uuid);
//1: 获取Authorization Code
String url = "https://graph.qq.com/oauth2.0/authorize?response_type=code" +
"&client_id=" + APPID +
"&redirect_uri=" + URLEncoder.encode(callback_url) +
"&state=" + uuid;
return new CommonResponse<>(200, "success", url);
}
/**
* QQ发起登录后的异步回调方法;直接通过域名访问是经过nginx所以地址要根据nginx的转发规则
* http://www.lyhosiris.cn/api/cloud-oauth2/qq_notify_url
*/
@RequestMapping("/qq_notify_url")
public String qq_notify_url(HttpServletRequest request, HttpServletResponse response) throws Exception {
response.setContentType("text/html; charset=utf-8");
HttpSession session = request.getSession();
//QQ返回的信息:http://graph.qq.com/demo/index.jsp?code=xxx&state=xxx
String code = request.getParameter("code");
String state = request.getParameter("state");
String uuid = (String) session.getAttribute("state");
if (uuid != null) {
if (!uuid.equals(state)) {
throw new Exception("QQd的state错误");
}
}
//2: 通过Authorization Code获取Access Token
String url = "https://graph.qq.com/oauth2.0/token?grant_type=authorization_code" +
"&client_id=" + APPID + "&client_secret=" + APPKEY + "&code=" + code + "&redirect_uri=" + callback_url;
String access_token = QQHttpClient.getAccessToken(url);
//3: 获取回调后的 openid 值
url = "https://graph.qq.com/oauth2.0/me?access_token=" + access_token;
String openId = QQHttpClient.getOpenID(url);
//4: 获取QQ用户信息
url = "https://graph.qq.com/user/get_user_info?access_token=" + access_token +
"&oauth_consumer_key=" + APPID + "&openid=" + openId;
//可放到Redis和mysql中
JSONObject jsonObject = QQHttpClient.getUserInfo(url);
String nkName = (String) jsonObject.get("nickname"); //QQ网名
String iconUrl = (String) jsonObject.get("figureurl_qq_2"); //100*100像素的QQ头像url
Map<String, String> parameters = new HashMap<>();
UmsMember ub = umsMemberService.CheckMemberOpenIdExists(openId); //根据openID查数据库有没有这个用户
Long id = null;
if (ub != null && ub.getOpenid() != null) { //如果有这个用户
id = ub.getId();
parameters.put("username", ub.getUsername());
parameters.put("password", ub.getPassword());
} else { //如果没有就去注册账号
String password = myPasswordEncoder.encode("abc123"); //自定义初始密码
assert openId != null;
String newUsername = openId.substring(0,10);
//远程调用用户服务,注册账号
Long rpc_user_id = umsMemberService.QQRegister(openId, newUsername, nkName, password, iconUrl);
if (rpc_user_id != null) { //注册成功返回id
id = rpc_user_id;
parameters.put("username", newUsername);
parameters.put("password", password);
} else {
return "window.alert('系统错误,注册失败');<script>window.close();</script>"; //注册失败...直接关闭
}
}
//注册或or登录成功返回我们自定义的token
String token = myTokenService.createMyToken(id, openId, parameters, myPasswordEncoder);
return "<script>window.opener.localStorage.setItem('Authorization', '\""+token+"\"');window.close();</script>";
}
- Security包装的用户信息,重要
/**
* Security包装的用户信息
*/
@Data
public class SecurityUserDetails implements UserDetails {
private Long id;
private String username;
private String password;
//用户状态
private Boolean enabled;
//权限数据
private Collection<SimpleGrantedAuthority> authorities;
//返回当前用户的权限
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.authorities;
}
public SecurityUserDetails(UmsMember umsMember, List<String> roles) {
this.id = umsMember.getId();
this.username = umsMember.getUsername();
this.password = umsMember.getPassword();
this.enabled = umsMember.getStatus() == 1;
if (roles != null && roles.size() > 0) {
authorities = new ArrayList<>();
roles.forEach(item -> authorities.add(new SimpleGrantedAuthority(item)));
}
}
public SecurityUserDetails(long id ,String username, String password, List<String> roles) {
this.id = id;
this.username = username;
this.password = password;
this.enabled = true;
if (roles != null && roles.size() > 0) {
authorities = new ArrayList<>();
roles.forEach(item -> authorities.add(new SimpleGrantedAuthority(item)));
}
}
/**
* 账户是否不过期,false即过期
*/
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* 是否不上锁,false即上锁
*/
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* 密码是否不过期,false即过期
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 是否启用
*/
@Override
public boolean isEnabled() {
return true;
}
}
Vue
登录页的 methods 方法中
//QQ登录方法 axios
ToQQLogin(){
var _this = this;
QQLogin().then(response=>{
if(response.data.code == 200){
var width = width || 720; var height = height || 460;
var left = (window.screen.width - width) / 2; var top = (window.screen.height - height) / 2;
var win = window.open(response.data.data,
"_blank","toolbar=yes,location=yes,directories=no,status=no,menubar=yes,scrollbars=yes,resizable=no,copyhistory=yes,left="+
left+",top="+top+",width="+width+",height="+height);
//监听登录窗口是否关闭,登录成功后端返回关闭窗口的代码;setInterval()方法会不停地调用函数,直到clearInterval()被调用或窗口被关闭
var listener = setInterval(function() {
//如果关闭了
if(win.closed){
if(getToken()){ //判断token是否存在
_this.$store.commit('SET_TOKEN', getToken());
clearInterval(listener);
_this.$router.push('/layout'); //跳转到首页,首页里根据token请求用户信息资料...
} else {
clearInterval(listener); //关闭弹框
}
}
},500);
}
});
}
请求的axios
个人写的网站:www.lyhosiris.cn
点击qq图标,请求验证服务器的接口 /QQLogin,返回一个url连接
这个连接用作用是:请求qq服务器的授权码Code【读过我前面文章ouath2授权码模式的应该知道】
用这个url打开一个 (windows.open) 新窗口去请求QQ服务器获取授权码
qq登录成功则返回 【授权码code】 到我们的回调地址,就是qq互联的回调地址
也就是这里
这里在调用qq请求工具,直接在服务端再次请求qq服务器获取token,openid,个人信息等等
然后解析,查看数据库中用户表中有没有这个openid的用户
如果注册失败则返回关闭弹框的js代码,前端就登录失败…
如果登录成功或注册成功则生成自定义token返回给前端存到 localStorage 中
生成token的代码是核心
我们的网站有两个登录的
1.我们自己网站的登录方法
2.第三方qq登录
3.微信登录…(还没做TT)
第一个:我们网站自己的登录,用的是security - oauth2中的密码模式
经过网关,请求token,验证服务器返回token,vue把token放在请求头中,请求用户数据
返回的token是经过验证服务器的/oauth/token端点返回的
我们这里配置过怎么生成的,以及自定义增强啊,和存储到哪。在这个类中
/oauth/token端点的执行的方法是框架内部的
我在代码中使用
@Resource
TokenEndpoint tokenEndpint;
注入,调用getAccessToken,不能执行成功
所以我们只能手动生成,分析他的工作流程
有一个实现类可以帮我们做:DefaultTokenServices
把我们的token增强类和存储类放入里面
然后构造一些【密码模式】必要的类和属性,这里初始密码当然要和前面注册的初始密码一样的才行,这个密码用户也不用知道。
然后创建权限列表(当然你也可以用数据库查询处理)
再创建包装的SecurityUserDetails,使得支持同样的校验
Authentication userAuth = new UsernamePasswordAuthenticationToken(userDetails, "[PROTECTED]", Arrays.asList(grantedAuthority));
这个就是密码模式的生成token的工具了,我们把SecurityUserDetails,和创建的权限列表放入生成Authentication
然后使用defaultTokenServices.createAccessToken() 生成token对象。当然也可以生成刷新token等等
这时候前端不管是qq登录,还是我们自己网站登录,使用rsa公钥,调用验证服务器校验端点,去校验token都是没问题的
这就是第三方和网关的统一登录,欢迎指出缺点
更多推荐
所有评论(0)