一 用户认证授权
1. 需求分析
1.1 用户认证与授权
什么是用户身份认证?
用户身份认证即用户去访问系统资源时系统要求验证用户的身份信息,身份合法方可继续访问。常见的用户身份认
证表现形式有:用户名密码登录,指纹打卡等方式。
什么是用户授权? 有权限的资源将无法访问,这个过程叫用户授权。
1.2 单点登陆需求
实际开发中 项目拥有多个子项目,为了提高用户体验性需要实现用户只认证一次便可以在多个拥有访问权限的系统中访问,这个功能就叫做单点登陆
1.3 第三方认证需求
作为互联网项目难免需要访问外部系统的资源,其实用场景如下:
一个微信用户没有学成在线中注册,本系统可以通过请求微信系统来验证该用户的身份,验证通过后,该用户便可在本系统学习,他的基本流程如下:
2. 用户认证技术方案
2.1 单点登陆技术方案
分布式系统要实现单点登陆,通常将认证系统独立抽取出来,并且将用户身份信息存储在单独的存储介质,比如 MySQL ,Redis 考虑性能要求,通常存储在Redis中
因为Redis的性能十分优越,可以支持每秒十几万次的读/写操作,并且它还支持持久化、集群部署、分布式、主从同步等,Redis在高并发的场景下数据的安全和一致性
单点登陆的特点是:
-
认证系统为独立的系统
-
各子系统通过http或其他协议与认证系统通信,完成用户认证
-
用户身份信息存储在Redis集群.
java中有很多用户认证的框架都可以实现单点登陆
-
Apache Shiro
-
CAS
-
Spring Security CAS
2.2 Qauth2认证
2.2.1 Qauth2认证流程
第三方认证技术方案最主要是解决认证协议的通用标准 问题,因为要实现 跨系统认证,各系统之间要遵循一定的
接口协议。
OAUTH协议为用户资源的授权提供了一个安全的、开放而又简易的标准。同时,任何第三方都可以使用OAUTH认
证服务,任何服务提供商都可以实现自身的OAUTH认证服务,因而OAUTH是开放的。业界提供了OAUTH的多种实
现如PHP、JavaScript,Java,Ruby等各种语言开发包,大大节约了程序员的时间,因而OAUTH是简易的。互联网
很多服务如Open API,很多大公司如Google,Yahoo,Microsoft等都提供了OAUTH认证服务,这些都足以说明
OAUTH标准逐渐成为开放资源授权的标准。
Oauth协议目前发展到2.0版本,1.0版本过于复杂,2.0版本已得到广泛应用。
参考:https://baike.baidu.com/item/oAuth/7153134?fr=aladdin
Oauth协议:https://tools.ietf.org/html/rfc6749
下边分析一个Oauth2认证的例子
-
用户登陆项目到认证系统选择第三方登陆
-
项目认证系统向第三方认证系统发送请求认证信息
-
第三方返回给用户授权页面
-
用户授权通过
-
第三方微信认证颁发授权码给项目认证系统
-
项目认证系统通过授权码,申请令牌
-
第三方校验授权码通过返回令牌
-
项目认证系统通过令牌访问第三方用户信息
-
第三方校验令牌合法性 合法则响应用户信息
-
项目认证系统展示获取的用户信息
注意:资源服务器和认证服务器可以是一个服务也可以分开的服务,如果是分开的服务资源服务器通常要请求认证
服务器来校验令牌的合法性。
Oauth2.0认证流程如下:
引自Oauth2.0协议rfc6749 https://tools.ietf.org/html/rfc6749
Oauth2包括以下角色:
1、客户端
本身不存储资源,需要通过资源拥有者的授权去请求资源服务器的资源,比如:###Android客户端、 ###Web客户端(浏览器端)、微信客户端等。
2、资源拥有者
通常为用户,也可以是应用程序,即该资源的拥有者。
3、授权服务器(也称认证服务器)
用来对资源拥有的身份进行认证、对访问资源进行授权。客户端要想访问资源需要通过认证服务器由资源拥有者授
权后方可访问。
4、资源服务器
存储资源的服务器,比如,学成网用户管理服务器存储了学成网的用户信息,学成网学习服务器存储了学生的学习
信息,微信的资源服务存储了微信的用户信息等。客户端最终访问资源服务器获取资源信息。
2.2.2 Oauth2在本项目的应用
Oauth2是一个标准的开放的授权协议,应用程序可以根据自己的要求去使用Oauth2,本项目使用Oauth2实现如
下目标:
-
项目访问第三方系统资源
-
外部系统访问项目的资源
-
项目前端(客户端)访问项目微服务的资源
-
项目微服务之前访问资源,例如微服务A访问微服务B的资源,B访问A的资源
2.3 Spring Security Oauth2认证解决方案
本项目采用 Spring security + Oauth2完成用户认证及用户授权,Spring security 是一个强大的和高度可定制的身
份验证和访问控制框架,Spring security 框架集成了Oauth2协议,下图是项目认证架构图:
3. Spring Security Oauth2研究
3.1 目标
本项目认证服务基于Spring Security Oauth2进行构建,并在其基础上作了一些扩展,采用JWT令牌机制,并自定
义了用户身份信息的内容。 本教程的主要目标是学习在项目中集成Spring Security Oauth2的方法和流程,通过
spring Security Oauth2的研究需要达到以下目标:
-
理解Oauth2的授权码流程及密码认证的流程
-
理解spring Security Oauth2的工作流程
-
掌握资源服务集成spring Security 框架完成Oauth2认证的流程
3.2 搭建认证服务
认证服务架构
3.2.1创建springboot项目
1 在application.yml中配置
server:
port: ${PORT:40400}
servlet:
context-path: /auth
spring:
application:
name: xc-service-ucenter-auth
redis:
host: ${REDIS_HOST:two}
port: ${REDIS_PORT:6379}
timeout: 5000 #连接超时 毫秒
jedis:
pool:
maxActive: 3
maxIdle: 3
minIdle: 1
maxWait: -1 #连接池最大等行时间 -1没有限制
password: 123456
datasource:
druid:
url: ${MYSQL_URL:jdbc:mysql://root:3306/xc_user?characterEncoding=utf-8}
username: root
password: 1234
driverClassName: com.mysql.jdbc.Driver
initialSize: 5 #初始建立连接数量
minIdle: 5 #最小连接数量
maxActive: 20 #最大连接数量
maxWait: 10000 #获取连接最大等待时间,毫秒
testOnBorrow: true #申请连接时检测连接是否有效
testOnReturn: false #归还连接时检测连接是否有效
timeBetweenEvictionRunsMillis: 60000 #配置间隔检测连接是否有效的时间(单位是毫秒)
minEvictableIdleTimeMillis: 300000 #连接在连接池的最小生存时间(毫秒)
auth:
tokenValiditySeconds: 1200 #token存储到redis的过期时间
clientId: XcWebApp
clientSecret: XcWebApp
cookieDomain: localhost
cookieMaxAge: -1
encrypt:
key-store:
locatio n: classpath:/xc.keystore
secret: xuechengkeystore
alias: xckey
password: xuecheng
eureka:
client:
registerWithEureka: true #服务注册开关
fetchRegistry: true #服务发现开关
serviceUrl: #Eureka客户端与Eureka服务端进行交互的地址,多个中间用逗号分隔
defaultZone: http://localhost:50101/eureka/
instance:
prefer-ip-address: true #将自己的ip地址注册到Eureka服务中
ip-address: ${IP_ADDRESS:localhost}
instance-id: ${spring.application.name}:${server.port} #指定实例id
ribbon:
MaxAutoRetries: 2 #最大重试次数,当Eureka中可以找到服务,但是服务连不上时将会重试,如果eureka中找不到服务则直接走断路器
MaxAutoRetriesNextServer: 3 #切换实例的重试次数
OkToRetryOnAllOperations: false #对所有操作请求都进行重试,如果是get则可以,如果是post,put等操作没有实现幂等的情况下是很危险的,所以设置为false
ConnectTimeout: 50000 #请求连接的超时时间
ReadTimeout: 60000 #请求处理的超时时间
pom.xml
<dependencies>
<dependency>
<groupId>com.xuecheng</groupId>
<artifactId>xc-framework-common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.xuecheng</groupId>
<artifactId>xc-service-api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>com.netflix.feign</groupId>
<artifactId>feign-okhttp</artifactId>
<version>8.18.0</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.10</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-orm</artifactId>
<version>${springframework.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
2 启动类配置
@EnableDiscoveryClient
@EnableFeignClients
@EntityScan("com.xuecheng.framework.domain.ucenter")//扫描实体类
@ComponentScan(basePackages={"com.xuecheng.api"})//扫描接口
@ComponentScan(basePackages={"com.xuecheng.framework"})//扫描common下的所有类
@SpringBootApplication
public class UcenterAuthApplication {
public static void main(String[] args) {
SpringApplication.run(UcenterAuthApplication.class, args);
}
@Bean
public RestTemplate restTemplate() {
return new RestTemplate(new OkHttp3ClientHttpRequestFactory());
}
}
3 config配置
AuthorizationServerConfig
@Configuration
@EnableAuthorizationServer
class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private DataSource dataSource;
//jwt令牌转换器
@Autowired
private JwtAccessTokenConverter jwtAccessTokenConverter;
@Autowired
UserDetailsService userDetailsService;
@Autowired
AuthenticationManager authenticationManager;
@Autowired
TokenStore tokenStore;
@Autowired
private CustomUserAuthenticationConverter customUserAuthenticationConverter;
//读取密钥的配置
@Bean("keyProp")
public KeyProperties keyProperties(){
return new KeyProperties();
}
@Resource(name = "keyProp")
private KeyProperties keyProperties;
//客户端配置
@Bean
public ClientDetailsService clientDetails() {
return new JdbcClientDetailsService(dataSource);
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.jdbc(this.dataSource).clients(this.clientDetails());
/* clients.inMemory()
.withClient("XcWebApp")//客户端id
.secret("XcWebApp")//密码,要保密
.accessTokenValiditySeconds(60)//访问令牌有效期
.refreshTokenValiditySeconds(60)//刷新令牌有效期
//授权客户端请求认证服务的类型authorization_code:根据授权码生成令牌,
// client_credentials:客户端认证,refresh_token:刷新令牌,password:密码方式认证
.authorizedGrantTypes("authorization_code", "client_credentials", "refresh_token", "password")
.scopes("app");//客户端范围,名称自定义,必填*/
}
//token的存储方法
// @Bean
// public InMemoryTokenStore tokenStore() {
// //将令牌存储到内存
// return new InMemoryTokenStore();
// }
// @Bean
// public TokenStore tokenStore(RedisConnectionFactory redisConnectionFactory){
// RedisTokenStore redisTokenStore = new RedisTokenStore(redisConnectionFactory);
// return redisTokenStore;
// }
@Bean
@Autowired
public TokenStore tokenStore(JwtAccessTokenConverter jwtAccessTokenConverter) {
return new JwtTokenStore(jwtAccessTokenConverter);
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter(CustomUserAuthenticationConverter customUserAuthenticationConverter) {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
KeyPair keyPair = new KeyStoreKeyFactory
(keyProperties.getKeyStore().getLocation(), keyProperties.getKeyStore().getSecret().toCharArray())
.getKeyPair(keyProperties.getKeyStore().getAlias(),keyProperties.getKeyStore().getPassword().toCharArray());
converter.setKeyPair(keyPair);
//配置自定义的CustomUserAuthenticationConverter
DefaultAccessTokenConverter accessTokenConverter = (DefaultAccessTokenConverter) converter.getAccessTokenConverter();
accessTokenConverter.setUserTokenConverter(customUserAuthenticationConverter);
return converter;
}
//授权服务器端点配置
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
/*Collection<TokenEnhancer> tokenEnhancers = applicationContext.getBeansOfType(TokenEnhancer.class).values();
TokenEnhancerChain tokenEnhancerChain=new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(new ArrayList<>(tokenEnhancers));
DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
defaultTokenServices.setReuseRefreshToken(true);
defaultTokenServices.setSupportRefreshToken(true);
defaultTokenServices.setTokenStore(tokenStore);
defaultTokenServices.setAccessTokenValiditySeconds(1111111);
defaultTokenServices.setRefreshTokenValiditySeconds(1111111);
defaultTokenServices.setTokenEnhancer(tokenEnhancerChain);
endpoints
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService)
//.tokenStore(tokenStore);
.tokenServices(defaultTokenServices);*/
endpoints.accessTokenConverter(jwtAccessTokenConverter)
.authenticationManager(authenticationManager)//认证管理器
.tokenStore(tokenStore)//令牌存储
.userDetailsService(userDetailsService);//用户信息service
}
//授权服务器的安全配置
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
// oauthServer.checkTokenAccess("isAuthenticated()");//校验token需要认证通过,可采用http basic认证
oauthServer.allowFormAuthenticationForClients()
.passwordEncoder(new BCryptPasswordEncoder())
.tokenKeyAccess("permitAll()")
.checkTokenAccess("isAuthenticated()");
}
}
CustomUserAuthenticationConverter
@Component
public class CustomUserAuthenticationConverter extends DefaultUserAuthenticationConverter {
@Autowired
UserDetailsService userDetailsService;
@Override
public Map<String, ?> convertUserAuthentication(Authentication authentication) {
LinkedHashMap response = new LinkedHashMap();
String name = authentication.getName();
response.put("user_name", name);
Object principal = authentication.getPrincipal();
UserJwt userJwt = null;
if(principal instanceof UserJwt){
userJwt = (UserJwt) principal;
}else{
//refresh_token默认不去调用userdetailService获取用户信息,这里我们手动去调用,得到 UserJwt
UserDetails userDetails = userDetailsService.loadUserByUsername(name);
userJwt = (UserJwt) userDetails;
}
response.put("name", userJwt.getName());
response.put("id", userJwt.getId());
response.put("utype",userJwt.getUtype());
response.put("userpic",userJwt.getUserpic());
response.put("companyId",userJwt.getCompanyId());
if (authentication.getAuthorities() != null && !authentication.getAuthorities().isEmpty()) {
response.put("authorities", AuthorityUtils.authorityListToSet(authentication.getAuthorities()));
}
return response;
}
}
WebSecurityConfig
@Configuration
@EnableWebSecurity
@Order(-1)
class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/userlogin","/userlogout","/userjwt");
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
AuthenticationManager manager = super.authenticationManagerBean();
return manager;
}
//采用bcrypt对密码进行编码
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.httpBasic().and()
.formLogin()
.and()
.authorizeRequests().anyRequest().authenticated();
}
}
4 Service
AuthService
/**
* @author 卫风
* @date 2020/6/4 18:02
*/
@Service
@Slf4j
public class AuthService {
final LoadBalancerClient loadBalancerClient;
final RestTemplate restTemplate;
final StringRedisTemplate redisTemplate;
@Value("${auth.tokenValiditySeconds}")
int tokenValiditySeconds;
public AuthService(LoadBalancerClient loadBalancerClient, RestTemplate restTemplate, StringRedisTemplate redisTemplate) {
this.loadBalancerClient = loadBalancerClient;
this.restTemplate = restTemplate;
this.redisTemplate = redisTemplate;
}
/**
* 用户认证申请令牌 将令牌存储到redis
* @param username
* @param password
* @param clientId
* @param clientSecret
* @return
*/
public AuthToken login(String username, String password, String clientId, String clientSecret) {
AuthToken authToken = this.applyToken(username, password, clientId, clientSecret);
if (authToken == null) {
ExceptionCast.cast(AuthCode.AUTH_LOGIN_APPLYTOKEN_FAIL);
}
//用户身份的令牌
String access_token = authToken.getAccess_token();
//存储到redis
//存储到redis中的内容
String content = JSON.toJSONString(authToken);
boolean token = this.saveToken(access_token, content, tokenValiditySeconds);
if (!token){
ExceptionCast.cast(AuthCode.AUTH_LOGIN_TOKEN_SAVEFAIL);
}
return authToken;
}
/**
* 存储到redis
* @param access_token 用户身份令牌
* @param content 内容就是authtoken对象的内容
* @param ttl 过期时间
* @return
*/
private boolean saveToken(String access_token,String content,long ttl){
String key = "user_token:" + access_token;
redisTemplate.boundValueOps(key).set(content,ttl, TimeUnit.SECONDS);
Long expire = redisTemplate.getExpire(key, TimeUnit.SECONDS);
if (expire == null){
expire = 0L;
}
return expire > 0;
}
/**
* 申请令牌
* @param username
* @param password
* @param clientId
* @param clientSecret
* @return
*/
private AuthToken applyToken(String username, String password, String clientId, String clientSecret){
//请求spring Security令牌
//从eureka中获取认证服务的地址(因为spring security 在认证服务中)
//从eureka中获取认证服务的一个实例的地址
ServiceInstance serviceInstance = loadBalancerClient.choose(XcServiceList.XC_SERVICE_UCENTER_AUTH);
URI uri = serviceInstance.getUri();
String authUrl = uri+"/auth/oauth/token";
/**
* url 就是申请令牌的url
* method http的方法类型
* requestEntity 请求内容
* responseType 将响应的结果生成的类型
*/
//请求的内容分为两部分
//1 . header 信息 , 包括了http basic认证信息
MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
String httpbasic = httpbasic(clientId, clientSecret);
headers.add("Authorization",httpbasic);
//2. 包括 : grant_type username password
MultiValueMap<String ,String > body = new LinkedMultiValueMap<>();
body.add("grant_type","password");
body.add("username",username);
body.add("password",password);
HttpEntity<MultiValueMap<String,String >> multiValueMapHttpEntity = new HttpEntity<>(body,headers);
//指定restTemplate当遇到400或401响应的时候也不要抛出异常,也要正常返回值
restTemplate.setErrorHandler(new DefaultResponseErrorHandler(){
@Override
public void handleError(ClientHttpResponse response) throws IOException {
//当响应的值为400或401的时候也要正常响应,不要抛出异常
if (response.getRawStatusCode() != 400 && response.getRawStatusCode() != 401) {
super.handleError(response);
}
}
});
//远程调用申请令牌
ResponseEntity<Map> exchange = restTemplate.exchange(authUrl, HttpMethod.POST, multiValueMapHttpEntity, Map.class);
Map body1 = exchange.getBody();
if (body1 == null ||
body1.get("access_token")==null ||
body1.get("refresh_token") == null ||
body1.get("jti")==null){
//解析spring security
if (body1!=null && body1.get("error_description")!=null){
String error_description = (String) body1.get("error_description");
if (error_description.contains("UserDetailsService returned null")){
ExceptionCast.cast(AuthCode.AUTH_ACCOUNT_NOTEXISTS);
}else if (error_description.contains("坏的凭证")){
ExceptionCast.cast(AuthCode.AUTH_CREDENTIAL_ERROR);
}
}
return null;
}
AuthToken authToken = new AuthToken();
authToken.setAccess_token((String)body1.get("jti"));
authToken.setRefresh_token((String)body1.get("refresh_token") );
authToken.setJwt_token((String)body1.get("access_token"));
return authToken;
}
/**
* 得到basic编码
* @param clientId
* @param clientSecret
* @return
*/
private String httpbasic(String clientId,String clientSecret){
//将客户端id和客户端密码拼接,按"客户端id,客户段密码"
String string = clientId+":"+clientSecret;
//进行base64编码
byte[] encode = Base64Utils.encode(string.getBytes());
return "Basic "+new String(encode);
}
/**
* 从redis查询令牌
* @param token
* @return
*/
public AuthToken getUserToken(String token){
String key = "user_token:"+token;
//从redis取到令牌信息
String value = redisTemplate.opsForValue().get(key);
try {
return JSON.parseObject(value, AuthToken.class);
} catch (Exception e) {
log.error("getUserToken from redis and execute JSON parseObject error {}",e.getMessage());
return null;
}
}
/**
* 从redis中删除令牌
* @param uid
*/
public void delToken(String uid) {
String key = "user_token:"+uid;
redisTemplate.delete(key);
}
}
UserDetailsServiceImpl
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
ClientDetailsService clientDetailsService;
@Autowired
UserClient userClient;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//取出身份,如果身份为空说明没有认证
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
//没有认证统一采用httpbasic认证,httpbasic中存储了client_id和client_secret,开始认证client_id和client_secret
if(authentication==null){
ClientDetails clientDetails = clientDetailsService.loadClientByClientId(username);
if(clientDetails!=null){
//密码
String clientSecret = clientDetails.getClientSecret();
return new User(username,clientSecret,AuthorityUtils.commaSeparatedStringToAuthorityList(""));
}
}
if (StringUtils.isEmpty(username)) {
return null;
}
// XcUserExt userExt = userClient.getUserExt(username);
// if(userExt == null){
// //表示用户不存在
// return null;
// }
XcUserExt userExt = new XcUserExt();
userext.setUsername("test");
userext.setPassword(new BCryptPasswordEncoder().encode("123"));
userExt.setPermissions(new ArrayList<XcMenu>());
//取出正确密码(hash值)
// String password = userExt.getPassword();
//这里暂时使用静态密码
String password ="123";
//用户权限,这里暂时使用静态数据,最终会从数据库读取
//从数据库获取权限
List<XcMenu> permissions = userExt.getPermissions();
List<String> user_permission = new ArrayList<>();
permissions.forEach(item-> user_permission.add(item.getCode()));
// user_permission.add("course_get_baseinfo");
// user_permission.add("course_find_pic");
String user_permission_string = StringUtils.join(user_permission.toArray(), ",");
UserJwt userDetails = new UserJwt(username,
password,
AuthorityUtils.commaSeparatedStringToAuthorityList(user_permission_string));
userDetails.setId(userExt.getId());
userDetails.setUtype(userExt.getUtype());//用户类型
userDetails.setCompanyId(userExt.getCompanyId());//所属企业
userDetails.setName(userExt.getName());//用户名称
userDetails.setUserpic(userExt.getUserpic());//用户头像
/* UserDetails userDetails = new org.springframework.security.core.userdetails.User(username,
password,
AuthorityUtils.commaSeparatedStringToAuthorityList(""));*/
// AuthorityUtils.createAuthorityList("course_get_baseinfo","course_get_list"));
return userDetails;
}
}
UserJwt
@Data
@ToString
public class UserJwt extends User {
private String id;
private String name;
private String userpic;
private String utype;
private String companyId;
public UserJwt(String username, String password, Collection<? extends GrantedAuthority> authorities) {
super(username, password, authorities);
}
}
数据库sql
链接:https://pan.baidu.com/s/1k9T0-ZgAw9iOFUZkhWw6Hw
提取码:xpv5
3.3 Oauth2授权模式
3.3.1 Oauth授权模式
Oauth有以下授权模式
授权码模式(Authorization Code) 隐式授权模式(Implicit) 密码模式(Resource Owner Password
Credentials) 客户端模式(Client Credentials)
其中授权码模式和密码模式应用较多
3.3.2 授权码流程
上边例举的第三方登陆使用的就是授权码模式,流程如下:
-
客户端请求第三方授权
-
用户(资源拥有者)同意给客户端授权
-
客户端获取到授权码,请求认证服务器获取令牌
-
认证服务器向客户端响应令牌
-
客户端请求资源服务器的资源,资源服务校验令牌合法性,完成授权
-
资源服务器返回受保护资源
3.3.3 申请授权码
请求认证服务获取授权码:
Get请求:
localhost:40400/auth/oauth/authorize? client_id=XcWebApp&response_type=code&scop=app&redirect_uri=http://localhost
输入账号和密码,点击Login。
Spring Security接收到请求会调用UserDetailsService接口的loadUserByUsername方法查询用户正确的密码。
当前导入的基础工程中将正确的密码硬编码为“123”,所以这里账号随意输入,密码输入123即可认证通过。
首先跳转到登录页面:
输入账号和密码,点击Login。
Spring Security接收到请求会调用UserDetailsService接口的loadUserByUsername方法查询用户正确的密码。
当前导入的基础工程中将正确的密码硬编码为“123”,所以这里账号随意输入,密码输入123即可认证通过。
3.3.4 申请令牌
拿到授权码后,申请令牌。
Post请求:http://localhost:40400/auth/oauth/token
参数如下:
grant_type:授权类型,填写authorization_code,表示授权码模式
code:授权码,就是刚刚获取的授权码,注意:授权码只使用一次就无效了,需要重新申请。
redirect_uri:申请授权码时的跳转url,一定和申请授权码时用的redirect_uri一致。
此链接需要使用 http Basic认证。
什么是http Basic认证?
http协议定义的一种认证方式,将客户端id和客户端密码按照“客户端ID:客户端密码”的格式拼接,并用base64编
码,放在header中请求服务端,一个例子:
Authorization:Basic WGNXZWJBcHA6WGNXZWJBcHA=WGNXZWJBcHA6WGNXZWJBcHA= 是用户名:密码的base64编码。
认证失败服务端返回 401 Unauthorized
以上测试使用postman完成:
http basic认证:
Post请求参数:
点击发送:
申请令牌成功:
access_token:访问令牌,携带此令牌访问资源
token_type:有MAC Token与Bearer Token两种类型,两种的校验算法不同,RFC 6750建议Oauth2采用 Bearer
Token(http://www.rfcreader.com/#rfc6750)。
refresh_token:刷新令牌,使用此令牌可以延长访问令牌的过期时间。
expires_in:过期时间,单位为秒。
scope:范围,与定义的客户端范围一致。
3.3.5资源服务授权
3.3.5.1 资源服务授权流程
资源服务拥有要访问的受保护资源,客户端携带令牌访问资源服务,如果令牌合法则可成功访问资源服务中的资
源,如下图:
-
客户端请求认证服务申请令牌
-
认证服务生成令牌,认证服务采用非对称加密算法,使用私钥生成令牌
-
客户携带令牌访问资源服务,客户端在Http header 中添加: Authorization : Bearer 令牌
-
资源服务请求认证服务校验令牌的有效性,资源服务接收到令牌,使用公钥校验令牌的合法性
-
令牌有效,资源服务向客户端响应资源信息
- 认证服务生成令牌采用非对称加密算法,认证服务采用私钥加密生成令牌,对外向资源服务提供公钥,资源服务使用公钥来检验令牌的合法性
将公钥拷贝到publickey.txt文件中,将此文件拷贝到资源服务工程的classpath下
添加依赖
Config包下ResourceServerConfig类配置
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
/**
* @author Administrator
* @version 1.0
**/
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)//激活方法上的PreAuthorize注解
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
//公钥
private static final String PUBLIC_KEY = "publickey.txt";
//定义JwtTokenStore,使用jwt令牌
@Bean
public TokenStore tokenStore(JwtAccessTokenConverter jwtAccessTokenConverter) {
return new JwtTokenStore(jwtAccessTokenConverter);
}
//定义JJwtAccessTokenConverter,使用jwt令牌
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setVerifierKey(getPubKey());
return converter;
}
/**
* 获取非对称加密公钥 Key
* @return 公钥 Key
*/
private String getPubKey() {
Resource resource = new ClassPathResource(PUBLIC_KEY);
try {
InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
BufferedReader br = new BufferedReader(inputStreamReader);
return br.lines().collect(Collectors.joining("\n"));
} catch (IOException ioe) {
return null;
}
}
//Http安全配置,对每个到达系统的http请求链接进行校验
//通过下边的配置虽然可以访问swagger-ui,但是无法进行单元测试,除非去掉认证的配置或在上边配置中添加所有 请求均放行("/**")。
@Override
public void configure(HttpSecurity http) throws Exception {
//所有请求必须认证通过
http.authorizeRequests()
//下边的路径放行
.antMatchers("/v2/api-docs", "/swagger-resources/configuration/ui",
"/swagger-resources","/swagger-resources/configuration/security",
"/swagger-ui.html","/webjars/**").permitAll()
.anyRequest().authenticated();
}
}
请求没有携带令牌则报错
get http://localhost:31200/course/coursepic/list/4028e58161bd3b380161bd3bcd2f0000
{
"error": "unauthorized",
"error_description": "Full authentication is required to access this resource"
}
请求时携带令牌:
在http header中添加 Authorization: Bearer 令牌
当输入错误的令牌也无法正常访问资源。
3.4 Oauth2密码模式授权
密码模式(Resource Owner Password Credentials)与授权码模式的区别是申请令牌不再使用授权码,而是直接
通过用户名和密码即可申请令牌。
测试如下:
Post请求:http://localhost:40400/auth/oauth/token
参数:
grant_type:密码模式授权填写password
username:账号
password:密码
并且此链接需要使用 http Basic认证。
注意:当令牌没有过期时同一个用户再次申请令牌则不再颁发新令牌。
3.4 校验令牌
Spring Security Oauth2提供校验令牌的端点,如下:
Get: http://localhost:40400/auth/oauth/check_token?token=
参数:
token:令牌
使用postman测试如下:
exp:过期时间,long类型,距离1970年的秒数(new Date().getTime()可得到当前时间距离1970年的毫秒数)。
user_name: 用户名
client_id:客户端Id,在oauth_client_details中配置
scope:客户端范围,在oauth_client_details表中配置
jti:与令牌对应的唯一标识
companyId、userpic、name、utype、id:这些字段是本认证服务在Spring Security基础上扩展的用户身份信息
3.4 刷新令牌
刷新令牌是当令牌快过期时重新生成一个令牌,它于授权码授权和密码授权生成令牌不同,刷新令牌不需要授权码
也不需要账号和密码,只需要一个刷新令牌、客户端id和客户端密码。
测试如下:
Post:http://localhost:40400/auth/oauth/token
参数:
grant_type: 固定为 refresh_token
refresh_token:刷新令牌(注意不是access_token,而是refresh_token)
刷新令牌成功,会重新生成新的访问令牌和刷新令牌,令牌的有效期也比旧令牌长。
刷新令牌通常是在令牌快过期时进行刷新。
3.6JWT研究
3.6.1JWT介绍
在介绍JWT之前先看一下传统校验令牌的方法,如下图:
传统授权方法的问题是用户每次请求资源服务,资源服务都需要携带令牌访问认证服务去校验令牌的合法性,并根
据令牌获取用户的相关信息,性能低下。
解决:
使用JWT的思路是,用户认证通过会得到一个JWT令牌,JWT令牌中已经包括了用户相关的信息,客户端只需要携带
JWT访问资源服务,资源服务根据事先约定的算法自行完成令牌校验,无需每次都请求认证服务完成授权。
JWT令牌授权过程如下图:
什么是JWT?
JSON Web Token(JWT)是一个开放的行业标准(RFC 7519),它定义了一种简介的、自包含的协议格式,用于
在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任。JWT可以使用HMAC算法或使用RSA的公
钥/私钥对来签名,防止被篡改。
标准:https://tools.ietf.org/html/rfc7519
JWT令牌的优点:
1、jwt基于json,非常方便解析。
2、可以在令牌中自定义丰富的内容,易扩展。
3、通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。
4、资源服务使用JWT可不依赖认证服务即可完成授权。
缺点:
1、JWT令牌较长,占存储空间比较大。
3.6.1.1令牌结构
通过学习JWT令牌结构为自定义JWT打好基础
JWT令牌由三部分组成,每部分中间使用点( . ) 分隔,比如 : xxxx.yyyy.zzzz
- Header
头部包括令牌的类型(既JWT ) 及使用的哈希算法 (如 HMAC SHA256或 RSA)
一个例子如下:
下边是Header部分的内容
{
"alg": "HS256",
"typ": "JWT"
}
将上边的内容使用Base64Url编码,得到一个字符串就是JWT令牌的第一部分。
- Payload
如:iss(签发者),exp(过期时间戳), sub(面向的用户)等,也可自定义字段。
此部分不建议存放敏感信息,因为此部分可以解码还原原始内容。
最后将第二部分负载使用Base64Url编码,得到一个字符串就是JWT令牌的第二部分。
一个例子:
第二部分是负载,内容也是一个json对象,它是存放有效信息的地方,它可以存放jwt提供的现成字段,比
{
"sub": "1234567890",
"name": "456",
"admin": true
}
- Signature
第三部分是签名,此部分用于防止jwt内容被篡改。
这个部分使用base64url将前两部分进行编码,编码后使用点(.)连接组成字符串,最后使用header中声明
签名算法进行签名。
一个例子:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
base64UrlEncode(header):jwt令牌的第一部分。
base64UrlEncode(payload):jwt令牌的第二部分。
secret:签名所使用的密钥。
3.6.1.2 JWT生成私钥和公钥
Spring Security 提供对JWT的支持,本节我们使用Spring Security 提供的JwtHelper来创建JWT令牌,校验JWT令牌等操作。
JWT令牌生成采用非对称加密算法
- 生成密钥证书
下边命令生成密钥证书,采用RSA算法每个证书包含公钥和私钥
Keytool 是一个java提供的证书管理工具
-alias:密钥的别名
-keyalg:使用的hash算法
-keypass:密钥的访问密码
-keystore:密钥库文件名,test.keystore保存了生成的证书
-storepass:密钥库的访问密码
根据提示输入即可
keytool -genkeypair -alias gulikey -keyalg RSA -keypass 123456 -keystore guli.keystore -storepass test1234
-
- 查询证书信息
- 查询证书信息
#keytool -list -keystore 密钥库文件名
keytool -list -keystore guli.keystore
-
- 删除别名
#keytool -delete -alias (密钥别名) -keystore 密钥库文件名
keytool -delete -alias testkey -keystore test.keystore
-
- 导出公钥
openssl是一个加解密工具包,这里使用openssl来导出公钥信息。
链接:https://pan.baidu.com/s/1VlpBAJDJLVusB522QDOj2A
提取码:00zc
下载安装后 配置path环境变量后即可 不行就重启下电脑
进入test.keystore文件所在目录执行如下命令:
下面这一段内容就是公钥
将上面的公钥拷贝到文本文件中,合并为一行 (因为空格有换行符,会导致错误)
- 导出公钥
keytool -list -rfc --keystore .\guli.keystore | openssl x509 -inform pem -pubkey
-----BEGIN PUBLIC KEY-----MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArc5gzgDy/ubSBkFlrd3WHt00KJbliu2j/AXkIUQR7CNYIJbYz1mjK1FAJs+Mh1TdzuIGdSaylACmkOGt58KYOFWurscBn9a4UWgXy+W6mF8q2zWflxdywrybj/p3FpnHJGHumSasHZ7M65hn/AeW5muyuwlsvrqrBmFCmbl/GO8AFEIeUgDSorZGOp87CY7rKbrybfqJpxiMEAq0OHJ5vXxyqrasrBk5S/OepDN5ff0Ds9A7KBrv+l33FL4ue0xKUfl+6KmQm6Pp5JJc0zWRNi44IHBzE003eCTb/Clm9U2t2cGJXHRQSLCmV7vAnCdg0Vc0h5x9bMVNP4Poq8NXLwIDAQAB-----END PUBLIC KEY-----
-----BEGIN PUBLIC KEY-----MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4YdHBudqE4Mybb//gnw7CejDXeWoxdxOs7OewTIx9G2xHu1wOlM4MHO3thOSRaL7nSzfjxzyR3d7UVpjCD1V9JfnjZFbv3SnAsJPFcKCSyreqCUW7FCZVRzoUFzOir+G0moTjQxtDJ65EfduuK56A+pS7MCG0olKZhK5ThL0Fbp0J7GKMLbbLf0TvkpBTjMMISsp3vKr3enfubY3leS6pKbWzepBho8wKlEc4Hpz2G+0uCasvGSbcwIqwYVzfpY49DrLFjj6k49Sq3n8zANfY+IEy+0bJnm7f4OGWczRZESegDRLj5flxmwc3fy9Ya0KghFB8UeHIAXVzhqfYPnbPQIDAQAB-----END PUBLIC KEY-----
3.6.1.3 生成jwt令牌
使用一项技术之前先测试下,测试jwt令牌的生成与验证
@SpringBootTest
@RunWith(SpringRunner.class)
public class TestJwt {
//创建jwt令牌
@Test
public void testCreateJwt(){
//密钥库文件
String keystore = "test.keystore";
//密钥库的密码
String keystore_password = "test1234";
//密钥库文件路径
ClassPathResource classPathResource = new ClassPathResource(keystore);
//密钥别名
String alias = "testkey";
//密钥的访问密码
String key_password = "123456";
//密钥工厂
KeyStoreKeyFactory keyStoreKeyFactory = new KeyStoreKeyFactory(classPathResource,keystore_password.toCharArray());
//密钥对(公钥和私钥)
KeyPair keyPair = keyStoreKeyFactory.getKeyPair(alias, key_password.toCharArray());
//获取私钥
RSAPrivateKey aPrivate = (RSAPrivateKey) keyPair.getPrivate();
//jwt令牌的内容
Map<String,String> body = new HashMap<>();
body.put("name","weifeng");
String bodyString = JSON.toJSONString(body);
//生成jwt令牌
Jwt jwt = JwtHelper.encode(bodyString, new RsaSigner(aPrivate));
//生成jwt令牌编码
String encoded = jwt.getEncoded();
System.out.println(encoded);
}
}
验证jwt令牌
//校验jwt令牌
@Test
public void testVerify(){
//公钥
String publickey = "-----BEGIN PUBLIC KEY-----MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArc5gzgDy/ubSBkFlrd3WHt00KJbliu2j/AXkIUQR7CNYIJbYz1mjK1FAJs+Mh1TdzuIGdSaylACmkOGt58KYOFWurscBn9a4UWgXy+W6mF8q2zWflxdywrybj/p3FpnHJGHumSasHZ7M65hn/AeW5muyuwlsvrqrBmFCmbl/GO8AFEIeUgDSorZGOp87CY7rKbrybfqJpxiMEAq0OHJ5vXxyqrasrBk5S/OepDN5ff0Ds9A7KBrv+l33FL4ue0xKUfl+6KmQm6Pp5JJc0zWRNi44IHBzE003eCTb/Clm9U2t2cGJXHRQSLCmV7vAnCdg0Vc0h5x9bMVNP4Poq8NXLwIDAQAB-----END PUBLIC KEY-----";
//jwt令牌
String jwtString = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoid2VpZmVuZyJ9.rLjbfU4ipnhxTjMJvnsp4Fpr79hKOS33EW12GY0IOKo335vHV7ifL9i-D93w6_etXSSdIaOSFcqPgBvmZ-PEHDJP31hvtVNIlVMsqUulruNR5PnD1ElopuW38EI7wQw5C9jc3gvb0t02B4znVBLjmP3mm9IIMmMYleBkVBkBsMrmHNShc3FJxZq0eo_jEu4uBpL5JDN02bnLuHQI339QHgJmS7oggkAwTqAlHP1R7qB7MwLL4T409maakQXjOlTlrlUyMpU-rD-FH6mpZlxozyttuVrPweKNdpyFzeFnD0IHOpkCWMD-1mkQ6DbxlGh1Bo78CQNu5TnsaIp2ae2aSg";
//校验jwt令牌
Jwt jwt = JwtHelper.decodeAndVerify(jwtString, new RsaVerifier(publickey));
//拿到jwt令牌中自定义的内容
String claims = jwt.getClaims();
System.out.println(claims);
}
4 认证接口开发
用户登陆的流程图如下
执行流程:
-
用户登陆,请求认证服务
-
认证服务通过,生成jwt令牌,将jwt令牌及相关信息写入redis,并且将身份令牌写入cookie
-
用户访问资源页面,带着cookie到网关
-
用户从cookie获取token,并查询redis校验token,如果token不存在则拒绝访问,否则放行
-
用户退出,请求认证服务,清除redis中的token,并且删除cookie中的token
使用redis存储用户的身份令牌有一下作用 -
- 实现用户退出注销功能,服务端清除令牌后,即使客户端请求携带token也是无效的
- 由于jwt令牌过长不宜存储在cookie中,所以将jwt存储在redis,有客户端请求服务端获取并在客户端存储
4. 1 认证服务
4.1 需求分析
认证服务需要实现的功能如下:
-
登陆接口
前端post提交账号,密码,用户身份校验通过,生成令牌,并将令牌存储到redis
将令牌写入cookie -
退出接口
校验当前用户的身份为合法并且为已登陆状态
将令牌从reids删除
删除cookie中的令牌
业务流程如下
4.1.2 Api接口
/**
* @author 卫风
* @date 2020/6/4 16:22
*/
@Api(value = "用户认证",description = "用户认证接口")
public interface AuthControllerApi {
/**
* 登陆
* @param loginRequest
* @return
*/
@ApiOperation("登陆")
public LoginResult login (LoginRequest loginRequest);
/**
* 退出
* @return
*/
@ApiOperation("退出")
public ResponseResult logout();
/**
* 查询用户jwt令牌
* @return
*/
@ApiOperation("查询用户jwt令牌")
public JwtResult userJwt();
}
4.1.3配置参数
application.yml中配置参数
auth:
tokenValiditySeconds: 1200 #token存储到redis的过期时间
clientId: XcWebApp
clientSecret: XcWebApp
cookieDomain: localhost
cookieMaxAge: -1
4.1.4申请令牌测试
为了不破坏Spring Security 的代码,我们在Service方法中通过RestTemplate请求spring Security 所暴露的申请令牌接口来申请令牌,下边是测试代码
/**
* @author 卫风
* @date 2020/6/4 17:00
*/
@SpringBootTest
@RunWith(SpringRunner.class)
public class TestClient {
@Autowired
LoadBalancerClient loadBalancerClient;
@Autowired
RestTemplate restTemplate;
@Test
public void testClient(){
ServiceInstance serviceInstance = loadBalancerClient.choose(XcServiceList.XC_SERVICE_UCENTER_AUTH);
URI uri = serviceInstance.getUri();
String authUrl = uri+"/auth/oauth/token";
/**
* url 就是申请令牌的url
* method http的方法类型
* requestEntity 请求内容
* responseType 将响应的结果生成的类型
*/
//请求的内容分为两部分
//1 . header 信息 , 包括了http basic认证信息
MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
String httpbasic = httpbasic("XcWebApp", "XcWebApp");
headers.add("Authorization",httpbasic);
//2. 包括 : grant_type username password
MultiValueMap<String ,String > body = new LinkedMultiValueMap<>();
body.add("grant_type","password");
body.add("username","test");
body.add("password","12322");
HttpEntity<MultiValueMap<String,String >> multiValueMapHttpEntity = new HttpEntity<>(body,headers);
//指定restTemplate当遇到400或401响应的时候也不要抛出异常,也要正常返回值
restTemplate.setErrorHandler(new DefaultResponseErrorHandler(){
@Override
public void handleError(ClientHttpResponse response) throws IOException {
//当响应的值为400或401的时候也要正常响应,不要抛出异常
if (response.getRawStatusCode() != 400 && response.getRawStatusCode() != 401) {
super.handleError(response);
}
}
});
//远程调用申请令牌
ResponseEntity<Map> exchange = restTemplate.exchange(authUrl, HttpMethod.POST, multiValueMapHttpEntity, Map.class);
Map body1 = exchange.getBody();
System.out.println(body1);
}
/**
* 得到basic编码
* @param clientId
* @param clientSecret
* @return
*/
private String httpbasic(String clientId,String clientSecret){
//将客户端id和客户端密码拼接,按"客户端id,客户段密码"
String string = clientId+":"+clientSecret;
//进行base64编码
byte[] encode = Base64Utils.encode(string.getBytes());
return "Basic "+new String(encode);
}
}
4.1.5service
调用认证服务申请令牌,并将令牌存储到redis
- AuthToken
创建AuthToken模型类,存储申请的令牌,包括身份令牌,刷新令牌,jwt令牌
身份令牌:用于校验用户是否认证
刷新令牌:jwt令牌快过期时执行刷新令牌
jwt令牌:用于授权
申请令牌的service方法如下:
@Data
@ToString
@NoArgsConstructor
public class AuthToken {
String access_token;//访问token
String refresh_token;//刷新token
String jwt_token;//jwt令牌
}
/**
* @author 卫风
* @date 2020/6/4 18:02
*/
@Service
@Slf4j
public class AuthService {
final LoadBalancerClient loadBalancerClient;
final RestTemplate restTemplate;
final StringRedisTemplate redisTemplate;
@Value("${auth.tokenValiditySeconds}")
int tokenValiditySeconds;
public AuthService(LoadBalancerClient loadBalancerClient, RestTemplate restTemplate, StringRedisTemplate redisTemplate) {
this.loadBalancerClient = loadBalancerClient;
this.restTemplate = restTemplate;
this.redisTemplate = redisTemplate;
}
/**
* 用户认证申请令牌 将令牌存储到redis
* @param username
* @param password
* @param clientId
* @param clientSecret
* @return
*/
public AuthToken login(String username, String password, String clientId, String clientSecret) {
AuthToken authToken = this.applyToken(username, password, clientId, clientSecret);
if (authToken == null) {
ExceptionCast.cast(AuthCode.AUTH_LOGIN_APPLYTOKEN_FAIL);
}
//用户身份的令牌
String access_token = authToken.getAccess_token();
//存储到redis
//存储到redis中的内容
String content = JSON.toJSONString(authToken);
boolean token = this.saveToken(access_token, content, tokenValiditySeconds);
if (!token){
ExceptionCast.cast(AuthCode.AUTH_LOGIN_TOKEN_SAVEFAIL);
}
return authToken;
}
/**
* 存储到redis
* @param access_token 用户身份令牌
* @param content 内容就是authtoken对象的内容
* @param ttl 过期时间
* @return
*/
private boolean saveToken(String access_token,String content,long ttl){
String key = "user_token:" + access_token;
redisTemplate.boundValueOps(key).set(content,ttl, TimeUnit.SECONDS);
Long expire = redisTemplate.getExpire(key, TimeUnit.SECONDS);
if (expire == null){
expire = 0L;
}
return expire > 0;
}
/**
* 申请令牌
* @param username
* @param password
* @param clientId
* @param clientSecret
* @return
*/
private AuthToken applyToken(String username, String password, String clientId, String clientSecret){
//请求spring Security令牌
//从eureka中获取认证服务的地址(因为spring security 在认证服务中)
//从eureka中获取认证服务的一个实例的地址
ServiceInstance serviceInstance = loadBalancerClient.choose(XcServiceList.XC_SERVICE_UCENTER_AUTH);
URI uri = serviceInstance.getUri();
String authUrl = uri+"/auth/oauth/token";
/**
* url 就是申请令牌的url
* method http的方法类型
* requestEntity 请求内容
* responseType 将响应的结果生成的类型
*/
//请求的内容分为两部分
//1 . header 信息 , 包括了http basic认证信息
MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
String httpbasic = httpbasic(clientId, clientSecret);
headers.add("Authorization",httpbasic);
//2. 包括 : grant_type username password
MultiValueMap<String ,String > body = new LinkedMultiValueMap<>();
body.add("grant_type","password");
body.add("username",username);
body.add("password",password);
HttpEntity<MultiValueMap<String,String >> multiValueMapHttpEntity = new HttpEntity<>(body,headers);
//指定restTemplate当遇到400或401响应的时候也不要抛出异常,也要正常返回值
restTemplate.setErrorHandler(new DefaultResponseErrorHandler(){
@Override
public void handleError(ClientHttpResponse response) throws IOException {
//当响应的值为400或401的时候也要正常响应,不要抛出异常
if (response.getRawStatusCode() != 400 && response.getRawStatusCode() != 401) {
super.handleError(response);
}
}
});
//远程调用申请令牌
ResponseEntity<Map> exchange = restTemplate.exchange(authUrl, HttpMethod.POST, multiValueMapHttpEntity, Map.class);
Map body1 = exchange.getBody();
if (body1 == null ||
body1.get("access_token")==null ||
body1.get("refresh_token") == null ||
body1.get("jti")==null){
//解析spring security
if (body1!=null && body1.get("error_description")!=null){
String error_description = (String) body1.get("error_description");
if (error_description.contains("UserDetailsService returned null")){
ExceptionCast.cast(AuthCode.AUTH_ACCOUNT_NOTEXISTS);
}else if (error_description.contains("坏的凭证")){
ExceptionCast.cast(AuthCode.AUTH_CREDENTIAL_ERROR);
}
}
return null;
}
AuthToken authToken = new AuthToken();
authToken.setAccess_token((String)body1.get("jti"));
authToken.setRefresh_token((String)body1.get("refresh_token") );
authToken.setJwt_token((String)body1.get("access_token"));
return authToken;
}
/**
* 得到basic编码
* @param clientId
* @param clientSecret
* @return
*/
private String httpbasic(String clientId,String clientSecret){
//将客户端id和客户端密码拼接,按"客户端id,客户段密码"
String string = clientId+":"+clientSecret;
//进行base64编码
byte[] encode = Base64Utils.encode(string.getBytes());
return "Basic "+new String(encode);
}
/**
* 从redis查询令牌
* @param token
* @return
*/
public AuthToken getUserToken(String token){
String key = "user_token:"+token;
//从redis取到令牌信息
String value = redisTemplate.opsForValue().get(key);
try {
return JSON.parseObject(value, AuthToken.class);
} catch (Exception e) {
log.error("getUserToken from redis and execute JSON parseObject error {}",e.getMessage());
return null;
}
}
/**
* 从redis中删除令牌
* @param uid
*/
public void delToken(String uid) {
String key = "user_token:"+uid;
redisTemplate.delete(key);
}
}
4.1.6 Controller
/**
* @author 卫风
* @date 2020/6/4 18:01
*/
@RestController
@RequestMapping("/")
public class AuthController implements AuthControllerApi {
@Value("${auth.clientId}")
String clientId;
@Value("${auth.clientSecret}")
String clientSecret;
@Value("${auth.cookieDomain}")
String cookieDomain;
@Value("${auth.cookieMaxAge}")
int cookieMaxAge;
final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
/**
* 登陆
*
* @param loginRequest
* @return
*/
@Override
@PostMapping("/userlogin")
public LoginResult login(LoginRequest loginRequest) {
if (loginRequest == null || StringUtils.isEmpty(loginRequest.getUsername()) ){
ExceptionCast.cast(AuthCode.AUTH_ACCOUNT_NOTEXISTS);
}
if (StringUtils.isEmpty(loginRequest.getPassword())){
ExceptionCast.cast(AuthCode.AUTH_PASSWORD_NONE);
}
//申请令牌
String username = loginRequest.getUsername();
String password = loginRequest.getPassword();
AuthToken authToken = authService.login(username,password,clientId,clientSecret);
String access_token = authToken.getAccess_token();
//将令牌存储到cookie
this.saveCookie(access_token);
return new LoginResult(CommonCode.SUCCESS,access_token);
}
/**
* 退出
*
* @return
*/
@Override
public ResponseResult logout() {
//取出身份令牌
String uid = getTokenFormCookie();
//删除redis中的token
this.authService.delToken(uid);
//清楚cookie
clearCookie(uid);
return new ResponseResult(CommonCode.SUCCESS);
}
/**
* 清除cookie
* @param uid
*/
private void clearCookie(String uid) {
//获取request
HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
CookieUtil.addCookie(response,cookieDomain,"/","uid",uid,0,false);
}
/**
* 查询用户jwt令牌
*
* @return
*/
@Override
@GetMapping("/userjwt")
public JwtResult userJwt() {
//取出cookie中的身份令牌
String tokenFormCookie = this.getTokenFormCookie();
if (tokenFormCookie == null){
return new JwtResult(CommonCode.FAIL,null);
}
//拿身份令牌从redis中查询jwt令牌
AuthToken userToken = authService.getUserToken(tokenFormCookie);
if (userToken!=null){
return new JwtResult(CommonCode.SUCCESS,userToken.getJwt_token());
}
//将jwt令牌返回给用户
return null;
}
/**
* 将令牌存储到cookie
* @param token
*/
private void saveCookie(String token){
//获取request
HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
CookieUtil.addCookie(response,cookieDomain,"/","uid",token,cookieMaxAge,false);
}
/**
* 得到身份令牌
* @return
*/
private String getTokenFormCookie(){
//获取request
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
Map<String, String> map = CookieUtil.readCookie(request, "uid");
if ( map!=null && map.get("uid")!=null){
return map.get("uid");
}
return null;
}
}
4.1.7登陆url放行
认证服务默认都有校验用户的身份信息,这里需要将登陆url方行
在WebSecurityConfig类中重写configure(WebSecurity web)方法
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/userlogin","/userlogout","/userjwt");
}
4.1.8测试认证接口
使用postman测试:
Post请求:http://localhost:40400/auth/userlogin
4.3.9测试写入Cookie
cookie最终会写到xuecheng.com域名下,可通过nginx代理进行认证,测试cookie是否写成功
- 配置nginx代理
在ucenter.xuecheng.com下配置代理路径
添加
\#认证
location ^~ /openapi/auth/ {
proxy_pass http://auth_server_pool/auth/;
}
1. \#认证服务
upstream auth_server_pool{
server 127.0.0.1:40400 weight=10;
}
- 请求: http://ucenter.xuecheng.com/openapi/auth/userlogin
观察cookie写入结果
2 用户认证流程分析
2.1 用户认证流程如下:
业务流程如下
-
客户端请求认证服务进行认证
-
认证服务认证通过后向浏览器cookie写入token(身份令牌)
认证服务请求用户中心查询用户信息
认证服务请求spring security 申请令牌
认证服务将token(身份令牌)和jwt令牌存储至redis中.
认证服务向cookie写入token(身份令牌) -
前端携带 token请求认证服务获取jwt令牌
前端获取到jwt令牌并存储在sessionStorage
前端从jwt令牌中解析用户信息并显示在页面 -
前端携带cookie中的token身份令牌既jwt令牌访问资源服务
前端请求资源服务需要携带两个token,一个是cookie中的身份令牌,一个是http header 中的jwt令牌
前端请求资源服务前在http header 上添加jwt请求资源 -
网关校验token的合法性
用户请求必须携带token身份令牌和jwt令牌
网关校验redis中token是否合法,已过期则要求用户重新登陆 -
资源服务校验jwt的合法性并完成授权
资源服务校验jwt令牌,完成授权,拥有权限的方法正常执行,没有权限的方法将拒绝访问
2.2认证服务查询数据库
2.2.1 需求分析
认证服务根据数据库中的用户信息去校验用户的身份,既校验账户和密码是否匹配
认证服务不直接连接数据库,而是通过用户中心服务去查询用户中心数据库
完整的流程图如下
2.2.2搭建环境 创建用户中心数据库
用户中心负责用户管理,包括: 用户信息管理 角色管理 权限管理
创建xc_user数据库(MySQL)
导入xc_user.sql
创建用户中心工程
2.2.3 查询用户接口
2.2.3.1Api接口
用户中心对外提供如下接口:
响应数据类型
此接口将来被用来查询用户信息及用户权限信息,所以这里定义扩展类型
@Data
@ToString
public class XcUserExt extends XcUser {
//权限信息
private List<XcMenu> permissions;
//企业信息
private String companyId;
}
@Data
@ToString
@Entity
@Table(name="xc_user")
@GenericGenerator(name = "jpa-uuid", strategy = "uuid")
public class XcUser {
@Id
@GeneratedValue(generator = "jpa-uuid")
@Column(length = 32)
private String id;
private String username;
private String password;
private String salt;
private String name;
private String utype;
private String birthday;
private String userpic;
private String sex;
private String email;
private String phone;
private String status;
@Column(name="create_time")
private Date createTime;
@Column(name="update_time")
private Date updateTime;
}
根据账号查询用户信息
/**
* @author 卫风
* @date 2020/6/5 10:43
*/
@Api(value = "用户中心",description = "用户管理中心")
public interface UcenterControllerApi {
@ApiOperation("根据用户账户查询用户信息")
public XcUserExt getUserExt(String username);
}
dao
使用spring data jpa 创建两个表的dao
/**
* @author 卫风
* @date 2020/6/5 10:48
*/
public interface XcUserRepository extends JpaRepository<XcUser,String > {
/**
* 根据账户名查询用户
* @param username
* @return
*/
XcUser findByUsername(String username);
}
/**
* @author 卫风
* @date 2020/6/5 10:48
*/
public interface XcCompanyUserRepository extends JpaRepository<XcCompanyUser,String > {
/**
* 根据用户id查询该用户所属的公司id
* @param userId
* @return
*/
XcCompanyUser findByUserId(String userId);
}
Service
/**
* @author 卫风
* @date 2020/6/5 11:09
*/
@Service
public class UserService {
final XcUserRepository xcUserRepository;
final XcCompanyUserRepository xcCompanyUserRepository;
public UserService(XcUserRepository xcUserRepository, XcCompanyUserRepository xcCompanyUserRepository) {
this.xcUserRepository = xcUserRepository;
this.xcCompanyUserRepository = xcCompanyUserRepository;
}
public XcUser findXcUserByUsername(String username){
return xcUserRepository.findByUsername(username);
}
/**
* 根据账户查询用户信息
* @param username
* @return
*/
public XcUserExt getUserExt(String username){
//根据账户查询xcUser信息
XcUser xcUser = this.findXcUserByUsername(username);
if (xcUser == null){
return null;
}
//根据用户id查询用户所属公司id
XcCompanyUser xcCompanyUser = xcCompanyUserRepository.findByUserId(xcUser.getId());
//去掉用户的公司id
String companyId = null;
if (xcCompanyUser!=null){
companyId = xcCompanyUser.getCompanyId();
}
XcUserExt xcUserExt = new XcUserExt();
BeanUtils.copyProperties(xcUser,xcUserExt);
xcUserExt.setCompanyId(companyId);
return xcUserExt;
}
}
Controller
/**
* @author 卫风
* @date 2020/6/5 15:11
*/
@RestController
@RequestMapping("/ucenter")
public class UcenterCOntroller implements UcenterControllerApi {
@Autowired
UserService userService;
@Override
@GetMapping("/getuserext")
public XcUserExt getUserExt( @RequestParam("username") String username) {
return userService.getUserExt(username);
}
}
3 调用查询用户接口
3.1 创建client
认证服务需要远程调用用户中心服务查询用户,在认证服务中创建Feign
客户端
/**
* @author 卫风
* @date 2020/6/5 15:15
*/
@FeignClient(value = XcServiceList.XC_SERVICE_UCENTER)
public interface UserClient {
/**
* 根据账户查询
*/
@GetMapping("/ucenter/getuserext")
public XcUserExt getUserExt(@RequestParam("username") String username);
}
3.2 UserDetailsServiceImpl
认证服务调用spring security 接口申请令牌,spring security接口会调用UserDetailsServiceImpl从数据库查询用户,如果查询不到则返回null,表示不存在;在UserDetailsServiceImpl中将正确的密码返回,spring security 会自动去比对输入密码的正确性
- 修改UserDetailsServiceImpl的loadUserByUsername方法,调用Ucenter服务的查询接口
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
ClientDetailsService clientDetailsService;
@Autowired
UserClient userClient;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//取出身份,如果身份为空说明没有认证
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
//没有认证统一采用httpbasic认证,httpbasic中存储了client_id和client_secret,开始认证client_id和client_secret
if(authentication==null){
ClientDetails clientDetails = clientDetailsService.loadClientByClientId(username);
if(clientDetails!=null){
//密码
String clientSecret = clientDetails.getClientSecret();
return new User(username,clientSecret,AuthorityUtils.commaSeparatedStringToAuthorityList(""));
}
}
if (StringUtils.isEmpty(username)) {
return null;
}
XcUserExt userExt = userClient.getUserExt(username);
if(userExt == null){
//表示用户不存在
return null;
}
// userext.setUsername("itcast");
// userext.setPassword(new BCryptPasswordEncoder().encode("123"));
userExt.setPermissions(new ArrayList<XcMenu>());
//取出正确密码(hash值)
String password = userExt.getPassword();
//这里暂时使用静态密码
// String password ="123";
//用户权限,这里暂时使用静态数据,最终会从数据库读取
//从数据库获取权限
List<XcMenu> permissions = userExt.getPermissions();
List<String> user_permission = new ArrayList<>();
permissions.forEach(item-> user_permission.add(item.getCode()));
// user_permission.add("course_get_baseinfo");
// user_permission.add("course_find_pic");
String user_permission_string = StringUtils.join(user_permission.toArray(), ",");
UserJwt userDetails = new UserJwt(username,
password,
AuthorityUtils.commaSeparatedStringToAuthorityList(user_permission_string));
userDetails.setId(userExt.getId());
userDetails.setUtype(userExt.getUtype());//用户类型
userDetails.setCompanyId(userExt.getCompanyId());//所属企业
userDetails.setName(userExt.getName());//用户名称
userDetails.setUserpic(userExt.getUserpic());//用户头像
/* UserDetails userDetails = new org.springframework.security.core.userdetails.User(username,
password,
AuthorityUtils.commaSeparatedStringToAuthorityList(""));*/
// AuthorityUtils.createAuthorityList("course_get_baseinfo","course_get_list"));
return userDetails;
}
}
- 测试,请求http://localhost:40400/auth/userlogin
观察UserDetailsServiceImpl是否正常请求Ucenter的查询用户接口。
3.3 BCryptPasswordEncoder
早期使用MD5对密码进行编码,每次算出的MD5值都一样,这样非常不安全,spring security推荐使用BCryptPasswordEncoder对密码加随机盐,每次的Hash值都不一样,安全性高.
@Test
public void testPasswordEncoder(){
String password = "111111";
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
for (int i = 0; i < 10; i++) {
//每个计算出的hash值都不一样
String encode = passwordEncoder.encode(password);
System.out.println(encode);
//虽然每次计算的密码hash值不一样但是校验是通过的
boolean matches = passwordEncoder.matches(password, encode);
System.out.println(matches);
}
}
运行后结果
使用方式
在AuthorizationServerConfifig配置类中配置BCryptPasswordEncoder
/**
* 采用bcrypt对密码进行编码
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
4 解析申请令牌错误信息
当账号输入错误信息应该返回用户不存在的信息,当密码错误要返回用户名或密码错误信息,业务流程图如下:
修改申请令牌的程序解析返回的数据:
由于restTemplate收到400或401的错误会抛出异常,而spring security针对账号不存在及密码错误会返回400及
401,所以在代码中控制针对400或401的响应不要抛出异常。
/**
* 申请令牌
* @param username
* @param password
* @param clientId
* @param clientSecret
* @return
*/
private AuthToken applyToken(String username, String password, String clientId, String clientSecret){
//请求spring Security令牌
//从eureka中获取认证服务的地址(因为spring security 在认证服务中)
//从eureka中获取认证服务的一个实例的地址
ServiceInstance serviceInstance = loadBalancerClient.choose(XcServiceList.XC_SERVICE_UCENTER_AUTH);
URI uri = serviceInstance.getUri();
String authUrl = uri+"/auth/oauth/token";
/**
* url 就是申请令牌的url
* method http的方法类型
* requestEntity 请求内容
* responseType 将响应的结果生成的类型
*/
//请求的内容分为两部分
//1 . header 信息 , 包括了http basic认证信息
MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
String httpbasic = httpbasic(clientId, clientSecret);
headers.add("Authorization",httpbasic);
//2. 包括 : grant_type username password
MultiValueMap<String ,String > body = new LinkedMultiValueMap<>();
body.add("grant_type","password");
body.add("username",username);
body.add("password",password);
HttpEntity<MultiValueMap<String,String >> multiValueMapHttpEntity = new HttpEntity<>(body,headers);
//指定restTemplate当遇到400或401响应的时候也不要抛出异常,也要正常返回值
restTemplate.setErrorHandler(new DefaultResponseErrorHandler(){
@Override
public void handleError(ClientHttpResponse response) throws IOException {
//当响应的值为400或401的时候也要正常响应,不要抛出异常
if (response.getRawStatusCode() != 400 && response.getRawStatusCode() != 401) {
super.handleError(response);
}
}
});
//远程调用申请令牌
ResponseEntity<Map> exchange = restTemplate.exchange(authUrl, HttpMethod.POST, multiValueMapHttpEntity, Map.class);
Map body1 = exchange.getBody();
if (body1 == null ||
body1.get("access_token")==null ||
body1.get("refresh_token") == null ||
body1.get("jti")==null){
//解析spring security
if (body1!=null && body1.get("error_description")!=null){
String error_description = (String) body1.get("error_description");
if (error_description.contains("UserDetailsService returned null")){
ExceptionCast.cast(AuthCode.AUTH_ACCOUNT_NOTEXISTS);
}else if (error_description.contains("坏的凭证")){
ExceptionCast.cast(AuthCode.AUTH_CREDENTIAL_ERROR);
}
}
return null;
}
AuthToken authToken = new AuthToken();
authToken.setAccess_token((String)body1.get("jti"));
authToken.setRefresh_token((String)body1.get("refresh_token") );
authToken.setJwt_token((String)body1.get("access_token"));
return authToken;
}
5 前端显示当前用户
5.1 需求分析
用户登陆成功在页头显示当前登陆的用户名
流程图如下:
-
用户请求认证服务,登陆成功
-
用户登陆成功,认证服务向cookie写入身份令牌,向redis中写入user_token(用户令牌及授权jwt令牌)
-
客户端携带cookie中的身份令牌请求认证服务获取jwt令牌
-
客户端解析jwt令牌,并将解析的用户信息存储到session Storage中
jwt令牌中包括了用户的基本信息,客户端解析jwt令牌即可获取用户信息 -
客户端从session storage中获取用户信息,并在页头显示
sessionStorage :
sessionStorage 是H5的一个会话存储对象,在SessionStorage中保存的数据只在同一窗口或同一标签页中有效,
在关闭窗口之后将会删除SessionStorage中的数据。
seesionStorage的存储方式采用key/value的方式,可保存5M左右的数据(不同的浏览器会有区别)。
5.2 jwt查询接口
5.2.1需求分析
认证服务对外提供jwt查询接口,流程如下:
-
客户端携带cookie中的身份令牌请求认证服务获取jwt
-
认证服务根据身份令牌从redis中查询jwt令牌并返回给客户端
API
在认证模块定义jwt查询接口
/**
* 查询用户jwt令牌
* @return
*/
@ApiOperation("查询用户jwt令牌")
public JwtResult userJwt();
Dao
无
Service
在AuthService定义方法如下:
/**
* 从redis查询令牌
* @param token
* @return
*/
public AuthToken getUserToken(String token){
String key = "user_token:"+token;
//从redis取到令牌信息
String value = redisTemplate.opsForValue().get(key);
try {
return JSON.parseObject(value, AuthToken.class);
} catch (Exception e) {
log.error("getUserToken from redis and execute JSON parseObject error {}",e.getMessage());
return null;
}
}
Controller
/**
* 查询用户jwt令牌
*
* @return
*/
@Override
@GetMapping("/userjwt")
public JwtResult userJwt() {
//取出cookie中的身份令牌
String tokenFormCookie = this.getTokenFormCookie();
if (tokenFormCookie == null){
return new JwtResult(CommonCode.FAIL,null);
}
//拿身份令牌从redis中查询jwt令牌
AuthToken userToken = authService.getUserToken(tokenFormCookie);
if (userToken!=null){
return new JwtResult(CommonCode.SUCCESS,userToken.getJwt_token());
}
//将jwt令牌返回给用户
return null;
}
/**
* 得到身份令牌
* @return
*/
private String getTokenFormCookie(){
//获取request
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
Map<String, String> map = CookieUtil.readCookie(request, "uid");
if ( map!=null && map.get("uid")!=null){
return map.get("uid");
}
return null;
}
测试
-
请求/auth/userlogin
-
- 观察cookie是否已存入用户身份令牌。get请求jwt
- 观察cookie是否已存入用户身份令牌。get请求jwt
6 用户退出
6.1 需求分析
操作流程如下:
-
删除redis中的token
-
删除cookie中的token
6.2 认证服务对外提供退出接口
/**
* 退出
* @return
*/
@ApiOperation("退出")
public ResponseResult logout();
6.3 Service
/**
* 从redis中删除令牌
* @param uid
*/
public void delToken(String uid) {
String key = "user_token:"+uid;
redisTemplate.delete(key);
}
6.4 Controller
/**
* 退出
*
* @return
*/
@Override
public ResponseResult logout() {
//取出身份令牌
String uid = getTokenFormCookie();
//删除redis中的token
this.authService.delToken(uid);
//清楚cookie
clearCookie(uid);
return new ResponseResult(CommonCode.SUCCESS);
}
/**
* 清除cookie
* @param uid
*/
private void clearCookie(String uid) {
//获取request
HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
CookieUtil.addCookie(response,cookieDomain,"/","uid",uid,0,false);
}
6.5 退出url放行
认证服务默认都要校验用户的身份信息,这里需要将退出url放行
在WebSecurityConfifig类中重写 confifigure(WebSecurity web)方法,如下:
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/userlogin","/userlogout","/userjwt");
}
3 Zuul网关
3.1 需求分析
网关的作用相当与一个过滤器,拦截器,它可以拦截多个系统的请求
3.2 Zuul介绍
什么事Zuul?
Spring Cloud Zuul 是整合Netfilx公司的Zuul开源项目实现的微服务网关,它实现了请求路由,负载均衡,校验过滤等功能
官方:https://github.com/Netflflix/zuul
什么是网关?
服务网关是在微服务前边设置一道屏障,请求先到服务网关,网关会对请求进行过滤,校验路由等处理,有了服务网关可以提高微服务的安全性,网关校验请求的合法性,请求不合法将被拦截,拒绝访问
Zull与Nginx怎么配合使用?
Zuul与Nginx在实际项目中需要配合使用,如下图,Nginx的作用是反向代理,负载均衡,Zuul的作用的保障微服务的安全访问,拦截微服务请求,校验合法性及负载均衡.
3.3搭建网关工程
3.3.1启动类配置
注意在启动类上使用@EnableZuulProxy注解标识此工程为Zuul网关,启动类代码如下:
/**
* @author Administrator
* @version 1.0
* @create 2018-07-17 12:03
**/
@SpringBootApplication
@EnableZuulProxy//此工程是一个zuul网关
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}
3.3.2路由配置
-
Zuul网关具有代理的功能,根据请求的url转发到微服务,如下图:
客户端请求网关/api/learning,通过路由转发到/learning
客户端请求网关/api/course 通过路由转发到/course -
在appcation.yml中的配置
serviceId:推荐使用serviceId,zuul会从Eureka中找到服务id对应的ip和端口。
strip-prefifix: false #true:代理转发时去掉前缀,false:代理转发时不去掉前缀,例如,为true请
求/course/coursebase/get/..,代理转发到/coursebase/get/,如果为false则代理转发到/course/coursebase/get
sensitiveHeaders:敏感头设置,默认会过虑掉cookie,这里设置为空表示不过虑
ignoredHeaders:可以设置过虑的头信息,默认为空表示不过虑任何头
http://localhost:50201/api是网关地址,通过路由转发到xc-service-manage-course服务。
由于课程管理已经添加了授课拦截,这里为了测试网关功能暂时将“/course/coursepic/list”url排除认证。
在课程管理服务的 ResourceServerConfifig类中添加"/course/coursepic/list/*",代码如下:
server:
port: 50201
servlet:
context-path: /api
spring:
application:
name: xc-govern-gateway
redis:
host: ${REDIS_HOST:two}
port: ${REDIS_PORT:6379}
timeout: 5000 #连接超时 毫秒
jedis:
pool:
maxActive: 3
maxIdle: 3
minIdle: 1
maxWait: -1 #连接池最大等行时间 -1没有限制
password: 123456
eureka:
client:
registerWithEureka: true #服务注册开关
fetchRegistry: true #服务发现开关
serviceUrl: #Eureka客户端与Eureka服务端进行交互的地址,多个中间用逗号分隔
defaultZone: ${EUREKA_SERVER:http://localhost:50101/eureka/}
instance:
prefer-ip-address: true #将自己的ip地址注册到Eureka服务中
ip-address: localhost
instance-id: ${spring.application.name}:${server.port} #指定实例id
ribbon:
MaxAutoRetries: 2 #最大重试次数,当Eureka中可以找到服务,但是服务连不上时将会重试,如果eureka中找不到服务则直接走断路器
MaxAutoRetriesNextServer: 3 #切换实例的重试次数
OkToRetryOnAllOperations: false #对所有操作请求都进行重试,如果是get则可以,如果是post,put等操作没有实现幂等的情况下是很危险的,所以设置为false
ConnectTimeout: 5000 #请求连接的超时时间
ReadTimeout: 6000 #请求处理的超时时间
zuul:
routes:
manage-course:
path: /course/**
serviceId: xc-service-manage-course #微服务名称,网关会从eureka中获取该服务名称下的服务实例的地址
#url: http://localhost:31200 #也可指定url
strip‐prefix: false #true:代理转发时去掉前缀,false:代理转发时不去掉前缀
sensitiveHeaders: #默认zuul会屏蔽cookie,cookie不会传到下游服务,这里设置为空则取消默认的黑名 单,如果设置了具体的头信息则不会传到下游服务
# ignoredHeaders: Authorization
xc-service-learning: #路由名称,名称任意,保持所有路由名称唯一
path: /learning/**
serviceId: xc-service-learning #指定服务id,从Eureka中找到服务的ip和端口
strip-prefix: false
sensitiveHeaders:
manage-cms:
path: /cms/**
serviceId: xc-service-manage-cms
strip-prefix: false
sensitiveHeaders:
manage-sys:
path: /sys/**
serviceId: xc-service-manage-cms
strip-prefix: false
sensitiveHeaders:
service-ucenter:
path: /ucenter/**
serviceId: xc-service-ucenter
sensitiveHeaders:
strip-prefix: false
xc-service-manage-order:
path: /order/**
serviceId: xc-service-manage-order
sensitiveHeaders:
strip-prefix: false
3.3.3过滤器配置
Zuul的核心功能就是过滤器,通过过滤器实现请求过滤,身份校验等
ZuulFilter
自定义过滤器需要继承ZuulFilter,ZuulFilter是一个抽象类,需要覆盖它的四个方法,如下:
-
shouldFilter : 返回一个Bookean值,判断该过滤器是否需要执行,返回true表示要执行此过滤器,否则不执行
-
run : 过滤器的业务逻辑
-
filterType : 返回字符串代表过滤器的类型 如下
pre : 请求在被路由之前执行
routing : 在路由请求时调用
post : 在routing 和error过滤器之后调用
error : 处理请求时发生错误调用 -
filterOrder : 此方法返回整型数值,通过此数值来定义过滤器的执行顺序,数字越小优先级越高
3.3.4身份校验
需求分析
-
从cookie查询用户身份令牌是否存在,不存在则拒绝访问
-
从http header查询jwt令牌是否存在,不存在则拒绝访问
-
从Redis查询user_token令牌是否过期,过期则拒绝访问
代码实现
- AuthService
说明: 由于令牌存储的时采用String 序列化策略,所以这里用StringRedisTemplate来查询,使用RedisTemplate无法完成查询
2定义LoginFilter
/**
* @author 卫风
* @date 2020/6/8 21:33
*/
@Service
public class AuthService {
@Autowired
StringRedisTemplate redisTemplate;
/**
* 从头取出jwt令牌
* @param request
* @return
*/
public String getJwtFromHeader(HttpServletRequest request){
String authorization = request.getHeader("Authorization");
if (StringUtils.isEmpty(authorization)){
return null;
}
if (!authorization.startsWith("Bearer ")){
return null;
}
return authorization.substring(7);
}
/**
* 从cookie中取出令牌
* @param request
* @return
*/
public String getTokenFromCookie(HttpServletRequest request){
Map<String,String> cookieMap = CookieUtil.readCookie(request,"uid");
String access_token = cookieMap.get("uid");
if (StringUtils.isEmpty(access_token)){
return null;
}
return access_token;
}
/**
* 查询令牌的有效期
* @param access_token
* @return
*/
public long getExpire(String access_token){
String key = "user_token:" + access_token;
return redisTemplate.getExpire(key, TimeUnit.SECONDS);
}
}
/**
* @author 卫风
* @date 2020/6/8 11:10
*/
@Component
public class LoginFilter extends ZuulFilter {
@Autowired
AuthService service;
/**
* 过滤器类型
* @return
*/
@Override
public String filterType() {
/**
* pre : 请求在路由之前执行
*
* routing: 在路由请求时调用
*
* post : 在routing和error过滤器之后调用
*
* error: 处理请求时发生错误调用
*
*/
return "pre";
}
/**
* 过滤器的序号 ,越小越被优先执行
* @return
*/
@Override
public int filterOrder() {
return 0;
}
@Override
public boolean shouldFilter() {
//返回true表示要执行此过滤器
return true;
}
/**
* 过滤器的内容
* 功能 : 过滤所有的请求,判断头部信息是否有Authorization.如果没有则拒绝访问,否则转发到微服务
* @return
* @throws ZuulException
*/
@Override
public Object run() throws ZuulException {
RequestContext requestContext = RequestContext.getCurrentContext();
//得到request
HttpServletRequest request = requestContext.getRequest();
//得到Authorization头
String authorization = request.getHeader("Authorization");
//得到response
String tokenFromCookie = service.getTokenFromCookie(request);
if (StringUtils.isEmpty(tokenFromCookie)){
//拒绝访问
this.access_denied();
}
//从header中取
String jwtFromHeader = service.getJwtFromHeader(request);
if (StringUtils.isEmpty(jwtFromHeader)){
this.access_denied();
}
//从redis中取得jwt的过期时间
long expire = service.getExpire(tokenFromCookie);
if (expire < 0 ){
this.access_denied();
return null;
}
return null;
}
private void access_denied(){
RequestContext requestContext = RequestContext.getCurrentContext();
//得到request
HttpServletRequest request = requestContext.getRequest();
HttpServletResponse response = requestContext.getResponse();
//拒绝访问
requestContext.setSendZuulResponse(false);
//设置响应的代码
requestContext.setResponseStatusCode(200);
//构建响应信息
ResponseResult responseResult = new ResponseResult(CommonCode.UNAUTHENTICATED);
String string = JSON.toJSONString(responseResult);
requestContext.setResponseBody(string);
//转成json 设置contentType
response.setContentType("application/json;charset=utf-8");
}
}
3.3.5测试
- 配置代理
通过nginx转发到gateway,在 www.xuecheng.com虚拟主机来配置
#user nobody;
worker_processes 1;
#error_log logs/error.log;
#error_log logs/error.log notice;
#error_log logs/error.log info;
#pid logs/nginx.pid;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
#cms页面预览
upstream cms_server_pool{
server 127.0.0.1:31001 weight=10;
}
#静态资源服务
upstream static_server_pool{
server 127.0.0.1:91 weight=10;
}
#前端动态门户
upstream dynamic_portal_server_pool{
server 127.0.0.1:10000 weight=10;
}
#后台搜索(公开api)
upstream search_server_pool{
server 127.0.0.1:40100 weight=10;
}
#认证
upstream auth_server_pool{
server 127.0.0.1:40400 weight=10;
}
#微服务网关
upstream api_server_pool {
server 127.0.0.1:50201 weight=10;
}
server{
listen 80;
server_name www.xuecheng.com;
ssi on;
ssi_silent_errors on;
location / {
alias G:/xcEdu/xcEduUT01/xc-ui-pc-static-portal/;
index index.html;
}
#静态资源,包括系统所需要的图片,js、css等静态资源
location /static/img/ {
alias G:/xcEdu/xcEduUT01/xc-ui-pc-static-portal/img/;
}
location /static/css/ {
alias G:/xcEdu/xcEduUT01/xc-ui-pc-static-portal/css/;
}
location /static/js/ {
alias G:/xcEdu/xcEduUT01/xc-ui-pc-static-portal/js/;
}
location /static/plugins/ {
alias G:/xcEdu/xcEduUT01/xc-ui-pc-static-portal/plugins/;
add_header Access‐Control‐Allow‐Origin http://ucenter.xuecheng.com;
add_header Access‐Control‐Allow‐Credentials true;
add_header Access‐Control‐Allow‐Methods GET;
}
location /static/company/{
proxy_pass http://static_server_pool;
}
location /static/teacher/ {
proxy_pass http://static_server_pool;
}
location /static/stat/ {
proxy_pass http://static_server_pool;
}
location /course/detail/{
proxy_pass http://static_server_pool;
}
#页面预览
location /cms/preview/ {
proxy_pass http://cms_server_pool/cms/preview/;
}
#前端门户课程搜索
location ^~ /course/search {
proxy_pass http://dynamic_portal_server_pool;
}
#后端搜索服务
location /openapi/search/ {
proxy_pass http://search_server_pool/search/;
}
#分类信息
location /static/category/ {
proxy_pass http://static_server_pool;
}
#开发环境webpack定时加载此文件
location ^~ /__webpack_hmr {
proxy_pass http://dynamic_portal_server_pool/__webpack_hmr;
}
#开发环境nuxt访问_nuxt
location ^~ /_nuxt/ {
proxy_pass http://dynamic_portal_server_pool/_nuxt/;
}
#微服务网关
location /api {
proxy_pass http://api_server_pool;
}
}
server{
listen 91;
server_name localhost;
#公司信息
location /static/company/ {
alias G:/xcEdu/static/company/;
}
#老师信息
location /static/teacher/ {
alias G:/xcEdu/static/teacher/;
}
#统计信息
location /static/stat/ {
alias G:/xcEdu/static/stat/;
}
location /course/detail/ {
alias G:/xcEdu/static/course/detail/;
}
#分类信息
location /static/category/ {
alias G:/xcEdu/static/category/;
}
}
# server{
# listen 80;
# server_name ucenter.xuecheng.com;
#
# #认证
# location ^~ /openapi/auth/ {
# proxy_pass http://auth_server_pool/auth/;
# }
#
#
#}
# map $http_origin $origin_list{
# default http:www.xuecheng.com;
# "~http://www.xuecheng.com" http://www.xuecheng.com;
# "~http://ucenter.xuecheng.com" http://ucenter.xuecheng.com;
#
#}
}
#前端教学管理
upstream teacher_server_pool{
server 127.0.0.1:12000 weight=10;
}
#文件服务
upstream filesystem_server_pool{
server 127.0.0.1:22100 weight=10;
}
#媒资服务
upstream media_server_pool{
server 127.0.0.1:31400 weight=10;
}
#学成网教学管理中心
server {
listen 80;
server_name teacher.xuecheng.com;
#个人中心
location / {
proxy_pass http://teacher_server_pool;
}
location /api {
proxy_pass http://api_server_pool;
}
location /filesystem {
proxy_pass http://filesystem_server_pool;
}
#媒资管理
location ^~ /api/media/ {
proxy_pass http://media_server_pool/media/;
}
#认证
location ^~ /openapi/auth/ {
proxy_pass http://auth_server_pool/auth/;
}
}
4 用户授权业务
用户授权的业务流程如下:
业务流程如下:
-
用户认证通过,认证服务像浏览器cookie写入token(身份令牌)
-
前端携带token请求用户中心服务获取jwt令牌
前端获取到jwt令牌解析,并存储在sessionStorage -
前端携带cookie中的身份令牌及jwt令牌访问资源服务
前端请求资源服务需要携带两个token 一个是cookie中的身份令牌,一个是http htader中的jwt
前端请求资源服务在http header上添加jwt请求资源 -
网关校验token的合法性
用户请求必须携带身份令牌和jwt令牌
网关校验redis中的user_token的有效期,已过期则要求用户重新登陆 -
资源服务校验jwt令牌的合法性并进行授权
资源服务校验jwt令牌,完成授权,拥有权限的方法正常执行,没有权限的方法拒绝访问
4.1 方法授权
- 需求分析
方法授权要完成的是资源服务根据jwt令牌完成对方法的授权,具体流程如下
-
- 生成jwt令牌时在令牌中写入用户所拥有的权限
我们给每个权限起个名字,例如某个用户拥有如下权限:
course_find_list : 课程查询
course_pic_list : 课程图片查询
- 生成jwt令牌时在令牌中写入用户所拥有的权限
- 在资源服务方法上添加注解 PreAuthOrize ,并指定此方法所需要的权限
例如下边是课程管理接口方法的授权配置,它就表示要执行这个方法需要拥有course_find_list权限
@Override
@GetMapping("/coursebase/list/{page}/{size}")
@PreAuthorize("hasAuthority('course_find_list')")
public QueryResponseResult<CourseInfo> findCourseList(@PathVariable("page") int page,@PathVariable("size") int size, CourseListRequest courseListRequest) {
return courseService.findCourseList(page,size,courseListRequest);
}
-
当请求有权限的方法时正常访问
-
当请求没有权限的方法时则拒绝访问
4.2 方法授权实现
4.2.1 资源服务添加授权控制
-
要想在资源服务使用方法授权,首先在资源服务配置授权控制
-
添加spring-cloud-starter-oauth2依赖
-
拷贝授权配置类ResourceServerConfig
-
拷贝公钥
4.2.2 方法上添加注解
通常情况下,程序员编写在资源服务的controller方法时会使用注解指定此方法的权限标识
- 查询课程列表方法
指定查询课程列表方法需要拥有course_find_list权限
@Override
@GetMapping("/coursebase/list/{page}/{size}")
@PreAuthorize("hasAuthority('course_find_list')")
public QueryResponseResult<CourseInfo> findCourseList(@PathVariable("page") int page,@PathVariable("size") int size, CourseListRequest courseListRequest) {
return courseService.findCourseList(page,size,courseListRequest);
}
- 在资源服务的ResourceServerConfig类似加注解,激活方法上添加注解
/**
* @author Administrator
* @version 1.0
**/
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)//激活方法上的PreAuthorize注解
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
//公钥
private static final String PUBLIC_KEY = "publickey.txt";
//定义JwtTokenStore,使用jwt令牌
@Bean
public TokenStore tokenStore(JwtAccessTokenConverter jwtAccessTokenConverter) {
return new JwtTokenStore(jwtAccessTokenConverter);
}
//定义JJwtAccessTokenConverter,使用jwt令牌
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setVerifierKey(getPubKey());
return converter;
}
/**
* 获取非对称加密公钥 Key
* @return 公钥 Key
*/
private String getPubKey() {
Resource resource = new ClassPathResource(PUBLIC_KEY);
try {
InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
BufferedReader br = new BufferedReader(inputStreamReader);
return br.lines().collect(Collectors.joining("\n"));
} catch (IOException ioe) {
return null;
}
}
//Http安全配置,对每个到达系统的http请求链接进行校验
@Override
public void configure(HttpSecurity http) throws Exception {
//所有请求必须认证通过
http.authorizeRequests()
//下边的路径放行
.antMatchers("/v2/api-docs", "/swagger-resources/configuration/ui",
"/swagger-resources","/swagger-resources/configuration/security",
"/swagger-ui.html","/webjars/**","/course/coursepic/list/**").permitAll()
.anyRequest().authenticated();
}
}
4.3 动态查询用户权限
4.3.1 需求分析
截至目前在测试授权时使用的权限数据时静态数据,正常情况的流程是:
-
管理员给用户分配权限,权限数据写道数据库中
-
认证服务在进行用户认证是从数据库读取用户的权限数据(动态数据)
4.3.2 权限数据模型
打卡xc_user数据库,找到下边的表
这五张表是标准的权限模型
xc_user: 用户表,存储了系统用户信息,用户类型包括: 学生,老师,管理员
xc_role: 角色表 存储了系统的角色信息,学生,老师,教学管理员,系统管理员等
xc_user_role: 用户角色表,一个用户可拥有多个角色,一个角色可被多个用户所拥有
xc_menu: 模块表 ,记录了菜单及菜单下的权限
xc_permission: 角色权限表,一个角色可拥有多个权限,一个权限可被多个角色所拥有
给用户分配权限 :
向已拥有角色分配权限 步骤:
确定用户的id
确定权限的id
确定用户的角色
向角色权限表添加记录
添加角色给用户分配权限 步骤 :
确定用户的id
确定权限的id
添加角色
向角色权限表添加记录
向用户角色关系表添加记录
4.3.3 用户中心查询用户权限
认证服务请求用户中心查询用户信息,用户需要将用户基本信息和用户权限一同返回给认证服务。
本小节实现用户查询查询用户权限,并将用户权限信息添加到的用户信息中返回给认证服务。
以上需求需要修改如下接口:
@Override
@GetMapping("/getuserext")
public XcUserExt getUserExt( @RequestParam("username") String username) {
return userService.getUserExt(username);
}
-
定义DAO
在用户中心服务中编写DAO,实现根据用户id查询权限 -
- 定义XcMenuMapper.java
/**
* @author 卫风
* @date 2020/6/9 12:10
*/
@Mapper
public interface XcMenuMapper {
/**
* 根据用户id查询用户的权限
* @param userId
* @return
*/
public List<XcMenu> selectPermissionByUserId(String userId);
}
-
- XcMenuMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.xuecheng.ucenter.dao.XcMenuMapper">
<select id="selectPermissionByUserId" resultType="com.xuecheng.framework.domain.ucenter.XcMenu" parameterType="java.lang.String">
SELECT
id,
CODE,
p_id pId,
menu_name menuName,
url,
is_menu isMenu,
LEVEL,
sort,
STATUS,
icon,
create_time createTime,
update_time updateTiem
FROM
xc_menu
WHERE id IN
(SELECT
menu_id
FROM
xc_permission
WHERE role_id IN
(SELECT
role_id
FROM
xc_user_role
WHERE user_id = #{userId}))
</select>
</mapper>
- Service
修改UserService的getUserExt方法,查询用户权限
/**
* 根据账户查询用户信息
* @param username
* @return
*/
public XcUserExt getUserExt(String username){
//根据账户查询xcUser信息
XcUser xcUser = this.findXcUserByUsername(username);
if (xcUser == null){
return null;
}
//根据用户id查询用户所属公司id
XcCompanyUser xcCompanyUser = this.xcCompanyUserRepository.findByUserId(xcUser.getId());
//查询用户的所有权限
List<XcMenu> xcMenus = this.xcMenuMapper.selectPermissionByUserId(xcUser.getId());
//去掉用户的公司id
String companyId = null;
if (xcCompanyUser!=null){
companyId = xcCompanyUser.getCompanyId();
}
XcUserExt xcUserExt = new XcUserExt();
BeanUtils.copyProperties(xcUser,xcUserExt);
xcUserExt.setCompanyId(companyId);
//设置权限
xcUserExt.setPermissions(xcMenus);
return xcUserExt;
}
- 修改认证服务查询用户权限
修改认证服务的UserDetailServiceImpl,查询用户的权限,并拼接权限串,讲原理的硬编码权限代码删除
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//取出身份,如果身份为空说明没有认证
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
//没有认证统一采用httpbasic认证,httpbasic中存储了client_id和client_secret,开始认证client_id和client_secret
if(authentication==null){
ClientDetails clientDetails = clientDetailsService.loadClientByClientId(username);
if(clientDetails!=null){
//密码
String clientSecret = clientDetails.getClientSecret();
return new User(username,clientSecret,AuthorityUtils.commaSeparatedStringToAuthorityList(""));
}
}
if (StringUtils.isEmpty(username)) {
return null;
}
XcUserExt userExt = userClient.getUserExt(username);
if(userExt == null){
//表示用户不存在
return null;
}
// userext.setUsername("itcast");
// userext.setPassword(new BCryptPasswordEncoder().encode("123"));
// userExt.setPermissions(new ArrayList<XcMenu>());
//取出正确密码(hash值)
String password = userExt.getPassword();
//这里暂时使用静态密码
// String password ="123";
//用户权限,这里暂时使用静态数据,最终会从数据库读取
//从数据库获取权限
List<XcMenu> permissions = userExt.getPermissions();
if (permissions == null){
permissions = new ArrayList<>();
}
List<String> user_permission = new ArrayList<>();
permissions.forEach(item-> user_permission.add(item.getCode()));
// user_permission.add("course_get_list");
// user_permission.add("course_get_baseinfo");
// user_permission.add("course_get_baseinfo");
// user_permission.add("course_find_pic");
String user_permission_string = StringUtils.join(user_permission.toArray(), ",");
UserJwt userDetails = new UserJwt(username,
password,
AuthorityUtils.commaSeparatedStringToAuthorityList(user_permission_string));
userDetails.setId(userExt.getId());
userDetails.setUtype(userExt.getUtype());//用户类型
userDetails.setCompanyId(userExt.getCompanyId());//所属企业
userDetails.setName(userExt.getName());//用户名称
userDetails.setUserpic(userExt.getUserpic());//用户头像
/* UserDetails userDetails = new org.springframework.security.core.userdetails.User(username,
password,
AuthorityUtils.commaSeparatedStringToAuthorityList(""));*/
// AuthorityUtils.createAuthorityList("course_get_baseinfo","course_get_list"));
return userDetails;
}
4.3.4 细粒度授权
什么是细粒度授权?
细粒度授权也叫数据范围授权,即不同的用户所拥有的操作权限相同,但是能够操作的数据范围是不一样的。一个
例子:用户A和用户B都是教学机构,他们都拥有“我的课程”权限,但是两个用户所查询到的数据是不一样的。
本项目有哪些细粒度授权?
比如:
我的课程,教学机构只允许查询本教学机构下的课程信息。
我的选课,学生只允许查询自己所选课。
如何实现细粒度授权?
细粒度授权涉及到不同的业务逻辑,通常在service层实现,根据不同的用户进行校验,根据不同的参数查询不同的
数据或操作不同的数据。
4.3.4.1 需求分析
1、我的课程查询,细粒度授权过程如下:
1)获取当前登录的用户Id
2)得到用户所属教育机构的Id
3)查询该教学机构下的课程信息
最终实现了用户只允许查询自己机构的课程信息。
2、修改课程管理服务“我的课程”的功能,根据公司Id查询课程,思路如下:
1)修改Dao,支持根据公司Id 查询课程。
2)修改Service,将公司Id传入Dao。
3)修改Controller,获取当前用户的公司Id,传给Service。
3、数据模型分析如下:
1)课程表
在xc_course数据库的course_base 表中添加company_id字段,来表示此课程的归属
2)用户企业表
在xc_user数据库的xc_company_user表中记录了用户的归属公司信息
通过xc_company_user表可得到用户的所属公司Id。
如何查询某个用户的课程?
1、确定用户的Id
2、根据用户的Id查询用户归属的公司。
3、根据公司Id查询该公司下的课程信息
Api
定义我的课程查询接口如下:
/**
* 查询我的课程列表
* @param page 页码
* @param size 每页显示数量
* @param courseListRequest
* @return
*/
@ApiOperation("查询我的课程列表")
public QueryResponseResult<CourseInfo> findCourseList(int page, int size, CourseListRequest courseListRequest);
修改dao
修改CourseMapper.xml的查询课程列表,添加companyId条件
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.xuecheng.manage_course.dao.CourseMapper">
<select id="findCourseBaseById" parameterType="java.lang.String"
resultType="com.xuecheng.framework.domain.course.CourseBase">
select * from course_base where id = #{id}
</select>
<select id="findCourseListPage" resultType="com.xuecheng.framework.domain.course.ext.CourseInfo" parameterType="com.xuecheng.framework.domain.course.request.CourseListRequest">
SELECT
course_base.*,
(SELECT
pic
FROM
course_pic
WHERE courseid = course_base.id) pic
FROM
course_base
WHERE company_id = #{companyId}
</select>
</mapper>
修改service
/**
* 分页查找
*
*
* @param id
* @param page
* @param size
* @param courseListRequest
* @return
*/
public QueryResponseResult<CourseInfo> findCourseList(String id, int page, int size, CourseListRequest courseListRequest) {
if (courseListRequest == null) {
courseListRequest = new CourseListRequest();
}
courseListRequest.setCompanyId(id);
if (page <= 0) {
page = 0;
}
if (size <= 0) {
size = 20;
}
//设置分页参数
PageHelper.startPage(page, size);
//分页查询
Page<CourseInfo> courseListPage = courseMapper.findCourseListPage(courseListRequest);
//得到查询列表
List<CourseInfo> result = courseListPage.getResult();
//总记录数
long total = courseListPage.getTotal();
//查询结果集
QueryResult<CourseInfo> courseInfoQueryResult = new QueryResult<>();
courseInfoQueryResult.setTotal(total);
courseInfoQueryResult.setList(result);
return new QueryResponseResult<CourseInfo>(CommonCode.SUCCESS, courseInfoQueryResult);
}
修改Controller
修改CourseController的findCourseList,向Servic传入companyId
@Override
@GetMapping("/coursebase/list/{page}/{size}")
// @PreAuthorize("hasAuthority('course_find_list')")
public QueryResponseResult<CourseInfo> findCourseList(@PathVariable("page") int page,@PathVariable("size") int size, CourseListRequest courseListRequest) {
//调用工具类取出用户信息
XcOauth2Util xcOauth2Util = new XcOauth2Util();
XcOauth2Util.UserJwt userJwt = xcOauth2Util.getUserJwtFromHeader(request);
if (userJwt == null){
ExceptionCast.cast(CommonCode.UNAUTHENTICATED);
}
//当前用户的id
String id = userJwt.getCompanyId();
return courseService.findCourseList(id,page,size,courseListRequest);
}
获取当前用户信息
要想实现只查询自己的课程信息则需要获取当前用户所属的企业id。
1、认证服务在用户认证通过将用户所属公司id等信息存储到jwt令牌中。
2、用户请求到达资源服务后,资源服务需要取出header中的jwt令牌,并解析出用户信息。
Jwt令牌包括企业id
资源服务在授权时需要用到用户所属企业ID,需要实现认证服务生成的JWT令牌中包括用户所属公司id信息。
查看认证服务UserDetailServiceImpl代码如下:
UserJwt userDetails = new UserJwt(username,
password,
AuthorityUtils.commaSeparatedStringToAuthorityList(user_permission_string));
userDetails.setId(userExt.getId());
userDetails.setUtype(userExt.getUtype());//用户类型
userDetails.setCompanyId(userExt.getCompanyId());//所属企业
userDetails.setName(userExt.getName());//用户名称
userDetails.setUserpic(userExt.getUserpic());//用户头像
/* UserDetails userDetails = new org.springframework.security.core.userdetails.User(username,
password,
AuthorityUtils.commaSeparatedStringToAuthorityList(""));*/
// AuthorityUtils.createAuthorityList("course_get_baseinfo","course_get_list"));
return userDetails;
通过上边代码的分析得知,认证服务调用XcUserExt userext = userClient.getUserext(username);获取用户信息,
将userext 中的信息存储到jwt令牌中,在userext 对象中已经包括了companyId公司ID等信息。
jwt解析工具类
在Oauth2Util工具类中,从header中获取jwt令牌,并解析jwt令牌内容
/**
* Created by mrt on 2018/5/25.
*/
public class Oauth2Util {
public static Map<String,String> getJwtClaimsFromHeader(HttpServletRequest request) {
if (request == null) {
return null;
}
//取出头信息
String authorization = request.getHeader("Authorization");
if (StringUtils.isEmpty(authorization) || authorization.indexOf("Bearer") < 0) {
return null;
}
//从Bearer 后边开始取出token
String token = authorization.substring(7);
Map<String,String> map = null;
try {
//解析jwt
Jwt decode = JwtHelper.decode(token);
//得到 jwt中的用户信息
String claims = decode.getClaims();
//将jwt转为Map
map = JSON.parseObject(claims, Map.class);
} catch (Exception e) {
e.printStackTrace();
}
return map;
}
}
- 在XcOauth2Util工具类中,将解析的jwt内容封装成UserJwt对象返回
/**
* Created by mrt on 2018/5/25.
*/
public class XcOauth2Util {
public UserJwt getUserJwtFromHeader(HttpServletRequest request){
Map<String, String> jwtClaims = Oauth2Util.getJwtClaimsFromHeader(request);
if(jwtClaims == null || StringUtils.isEmpty(jwtClaims.get("id"))){
return null;
}
UserJwt userJwt = new UserJwt();
userJwt.setId(jwtClaims.get("id"));
userJwt.setName(jwtClaims.get("name"));
userJwt.setCompanyId(jwtClaims.get("companyId"));
userJwt.setUtype(jwtClaims.get("utype"));
userJwt.setUserpic(jwtClaims.get("userpic"));
return userJwt;
}
@Data
public class UserJwt{
private String id;
private String name;
private String userpic;
private String utype;
private String companyId;
}
}
5 微服务之前认证
前边章节已经实现了用户携带身份令牌和JWT令牌访问微服务,微服务获取jwt并完成授权。
当微服务访问微服务,此时如果没有携带JWT则微服务会在授权时报错。
5.1 将服务添加进授权配置
- 添加依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
- 添加配置类
/**
* @author Administrator
* @version 1.0
**/
@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)//激活方法上的PreAuthorize注解
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
//公钥
private static final String PUBLIC_KEY = "publickey.txt";
//定义JwtTokenStore,使用jwt令牌
@Bean
public TokenStore tokenStore(JwtAccessTokenConverter jwtAccessTokenConverter) {
return new JwtTokenStore(jwtAccessTokenConverter);
}
//定义JJwtAccessTokenConverter,使用jwt令牌
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setVerifierKey(getPubKey());
return converter;
}
/**
* 获取非对称加密公钥 Key
* @return 公钥 Key
*/
private String getPubKey() {
Resource resource = new ClassPathResource(PUBLIC_KEY);
try {
InputStreamReader inputStreamReader = new InputStreamReader(resource.getInputStream());
BufferedReader br = new BufferedReader(inputStreamReader);
return br.lines().collect(Collectors.joining("\n"));
} catch (IOException ioe) {
return null;
}
}
//Http安全配置,对每个到达系统的http请求链接进行校验
@Override
public void configure(HttpSecurity http) throws Exception {
//所有请求必须认证通过
http.authorizeRequests()
//下边的路径放行
.antMatchers("/v2/api-docs", "/swagger-resources/configuration/ui",
"/swagger-resources","/swagger-resources/configuration/security",
"/swagger-ui.html","/webjars/**","/course/coursepic/list/**").permitAll()
.anyRequest().authenticated();
}
}
- 把公钥放在resource目录下
微服务直接调用需要携带jwt令牌
-----BEGIN PUBLIC KEY-----MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjTFzj+JN1ioYa/7tvZiQK7nYSzCpBpqN6BYo65GCHrHIl09/HObwtkMgXmjJNoa3tnYj9fajTJKwT20Yzl8hc95zeiZhg9yDRA5DQyom1afI6sURQ4RsxBSrKRjAQqRTCdaBwnkCng0+eVYq5xPI80nqAQIylqk1DWq6R4l91jIk1l6ceA6cbILXSXkZa1RGfsHmPjqM0TG8zkzfD1xe/yyCYgFlENlfw1tlxff/l7cuYV42rGOCqDGMsAbGrz4J50nEXPNvxGAKp21PrDQSikI+YtKlyA9g/4/mV9kSbOuJXsAGuE+ErvFVmCXCzl+JPiGXXaY/lSUVC32VnfV1UQIDAQAB-----END PUBLIC KEY-----
5.2 Feign拦截器
- 定义Feign拦截器
微服务之前使用feign进行远程调用,采用feign拦截器实现远程调用携带jwt
在common工程添加依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
- 在Common工程下定义拦截器如下:
/**Feign拦截器
* @author 卫风
* @date 2020/6/10 11:18
*/
public class FeignClientInterceptor implements RequestInterceptor {
/**
* Called for every request. Add data using methods on the supplied {@link RequestTemplate}.
*
* @param template
*/
@Override
public void apply(RequestTemplate template) {
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (requestAttributes != null){
HttpServletRequest request = requestAttributes.getRequest();
//取出当前请求的header,找到jwt令牌
Enumeration<String> headerNames = request.getHeaderNames();
if (headerNames!=null){
while (headerNames.hasMoreElements()){
String headerName = headerNames.nextElement();
String headerValue = request.getHeader(headerName);
//将header向下传递
template.header(headerName, headerValue);
}
}
}
}
}
- 在需要用的微服务定义bean
@Bean
public FeignClientInterceptor getFeignClientInterceptor(){
return new FeignClientInterceptor();
}