两个重要概念:认证服务 、授权服务
主要流程
用户在登入、切换角色 时,通过认证服务验证,认证通过后,认证服务会返回一个 token,前端存这个 token ,当用户访问其他资源时,授权服务 通过这个 token 获取到实际的用户信息,根据配置的资源规则来判断是否能够访问。
认证服务
通过配置开启授权服务器
@EnableAuthorizationServer // 开启认证服务器的功能
@Configuration
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter
最主要就是通过配置 UserdetailService 来实现我们自己的用户信息校验,通过上面的认证校验后,框架就会把相关的用户角色信息通过 生成 token,而我们这里使用的是 jwt 生成保存 token
AuthorizationServerConfig.Class 中
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService)
.tokenStore(jwtTokenStore())// tokenStore 来存储我们的token 这这里使用jwt 存储token
.tokenEnhancer(jwtAccessTokenConverter());
super.configure(endpoints);
}
这里为什么使用 jwt 作为 tokenStore 呢?jwt 是直接讲用户信息一起编码加密的字符串,
如果不使用 jwt 每次访问其他微服务时,都需要通过授权服务器拿 token 去认证服务器去获取用户信息,这样就会造成认证服务的访问压力过大。设置为 jwt 之后,资源服务器只需要自己解析 token 就能获得用户信息了,这是十分方便的
通过重写 UserDetailsService 的 loadUserByUsername 方法,认证用户,对认证通过的用户,讲用户角色信息保存在本次访问的上下文
package com.bjsxt.service.impl;
import com.bjsxt.constant.LoginConstant;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class UserServiceDetailsServiceImpl implements UserDetailsService {
@Autowired
private JdbcTemplate jdbcTemplate;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
String loginType = requestAttributes.getRequest().getParameter("login_type"); // 区分时后台人员还是我们的用户登录
if (StringUtils.isEmpty(loginType)) {
throw new AuthenticationServiceException("登录类型不能为null");
}
UserDetails userDetails = null;
try {
String grantType = requestAttributes.getRequest().getParameter("grant_type"); // refresh_token 进行纠正
if (LoginConstant.REFRESH_TYPE.equals(grantType.toUpperCase())) {
username = adjustUsername(username, loginType);
}
switch (loginType) {
case LoginConstant.ADMIN_TYPE:
userDetails = loadSysUserByUsername(username);
break;
case LoginConstant.MEMBER_TYPE:
userDetails = loadMemberUserByUsername(username);
break;
default:
throw new AuthenticationServiceException("暂不支持的登录方式:" + loginType);
}
} catch (IncorrectResultSizeDataAccessException e) { // 我们的用户不存在
throw new UsernameNotFoundException("用户名" + username + "不存在");
}
return userDetails;
}
/**
* 纠正用户的名称
*
* @param username 用户的id
* @param loginType admin_type member_type
* @return
*/
private String adjustUsername(String username, String loginType) {
if (LoginConstant.ADMIN_TYPE.equals(loginType)) {
// 管理员的纠正方式
return jdbcTemplate.queryForObject(LoginConstant.QUERY_ADMIN_USER_WITH_ID,String.class ,username);
}
if (LoginConstant.MEMBER_TYPE.equals(loginType)) {
// 会员的纠正方式
return jdbcTemplate.queryForObject(LoginConstant.QUERY_MEMBER_USER_WITH_ID,String.class ,username);
}
return username;
}
/**
* 后台人员的登录
*
* @param username
* @return
*/
private UserDetails loadSysUserByUsername(String username) {
// 1 使用用户名查询用户
return jdbcTemplate.queryForObject(LoginConstant.QUERY_ADMIN_SQL, new RowMapper<User>() {
@Override
public User mapRow(ResultSet rs, int rowNum) throws SQLException {
if (rs.wasNull()) {
throw new UsernameNotFoundException("用户名" + username + "不存在");
}
long id = rs.getLong("id"); // 用户的id
String password = rs.getString("password"); // 用户的密码
int status = rs.getInt("status");
return new User( // 3 封装成一个UserDetails对象,返回
String.valueOf(id), //使用id->username
password,
status == 1,
true,
true,
true,
getSysUserPermissions(id)
);
}
}, username);
}
/**
* // 2 查询这个用户对应的权限
* 通过用户的id 查询用户的权限
*
* @param id
* @return
*/
private Collection<? extends GrantedAuthority> getSysUserPermissions(long id) {
// 1 当用户为超级管理员时,他拥有所有的权限数据
String roleCode = jdbcTemplate.queryForObject(LoginConstant.QUERY_ROLE_CODE_SQL, String.class, id);
List<String> permissions = null; // 权限的名称
if (LoginConstant.ADMIN_ROLE_CODE.equals(roleCode)) { // 超级用户
permissions = jdbcTemplate.queryForList(LoginConstant.QUERY_ALL_PERMISSIONS, String.class);
} else { // 2 普通用户,需要使用角色->权限数据
permissions = jdbcTemplate.queryForList(LoginConstant.QUERY_PERMISSION_SQL, String.class, id);
}
if (permissions == null || permissions.isEmpty()) {
return Collections.emptySet();
}
return permissions.stream()
.distinct() // 去重
.map(perm -> new SimpleGrantedAuthority(perm))
.collect(Collectors.toSet());
}
/**
* 会员的登录
*
* @param username
* @return
*/
private UserDetails loadMemberUserByUsername(String username) {
return jdbcTemplate.queryForObject(LoginConstant.QUERY_MEMBER_SQL, new RowMapper<User>() {
@Override
public User mapRow(ResultSet rs, int rowNum) throws SQLException {
if (rs.wasNull()) {
throw new UsernameNotFoundException("用户:" + username + "不存在");
}
long id = rs.getLong("id"); // 会员的id
String password = rs.getString("password");// 会员的登录密码
int status = rs.getInt("status"); // 会员的状态
return new User(
String.valueOf(id),
password,
status == 1,
true,
true,
true,
Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"))
);
}
}, username, username);
}
}
我们实现登入接口,就可以直接返回 token 给前端,前端后续访问就用对应的 token 操作即可
public LoginUser login(LoginForm loginForm) {
log.info("用户{}开始登录", loginForm.getUsername());
checkFormData(loginForm);
LoginUser loginUser = null;
// 登录就是使用用户名和密码换一个token 而已--->远程调用->authorization-server
ResponseEntity<JwtToken> tokenResponseEntity = oAuth2FeignClient.getToken("password", loginForm.getUsername(), loginForm.getPassword(), "member_type", basicToken);
if (tokenResponseEntity.getStatusCode() == HttpStatus.OK) {
JwtToken jwtToken = tokenResponseEntity.getBody();
log.info("远程调用成功,结果为", JSON.toJSONString(jwtToken, true));
// token 必须包含bearer
loginUser = new LoginUser(loginForm.getUsername(), jwtToken.getExpiresIn(), jwtToken.getTokenType() + " " + jwtToken.getAccessToken(), jwtToken.getRefreshToken());
// 使用网关解决登出的问题:
// token 是直接存储的
strRedisTemplate.opsForValue().set(jwtToken.getAccessToken(), "", jwtToken.getExpiresIn(), TimeUnit.SECONDS);
}
return loginUser;
}
这里为什么使用 redis 又存了一次 token 呢?其实是因为当用户如果登出或者切换角色信息后,之前的 token 如果没过期,却仍然可以继续使用,所以我们要多添加一段 redis 判断的逻辑,防止其他人或前端拿实际已经过期的 token 仍然可以访问
授权服务
前面说过,授权服务其实就是一个权限校验的模块,这个可以配置在公共包中共所有服务引用去校验权限,当然 jwt 的 tokenStore 因为认证和授权服务都需要用到,也应该放到公共的包里。下面是一个动态权限校验的例子,拿到用户信息后,去判断当前用户角色是否具备访问的 url 的权限
package com.bjsxt.config.resource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import org.springframework.stereotype.Component;
import org.springframework.util.FileCopyUtils;
import javax.servlet.http.HttpServletRequest;
import java.util.Set;
import java.util.stream.Collectors;
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
@Configuration
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf()
.disable()
.sessionManagement().disable()
.authorizeRequests()
.antMatchers(
"/markets/kline/**" ,
"/users/setPassword" ,
"/users/register",
"/sms/sendTo",
"/gt/register" ,
"/login" ,
"/v2/api-docs",
"/swagger-resources/configuration/ui",//用来获取支持的动作
"/swagger-resources",//用来获取api-docs的URI
"/swagger-resources/configuration/security",//安全选项
"/webjars/**",
"/swagger-ui.html"
).permitAll()// antMatchers 后的 url permitAll 不需要校验权限
//antMatchers 后的 access 使用 el表达式校验权限,取 authService 的canAccess(request,authentication) 方法判断 true or false
.antMatchers("/**").access("@authService.canAccess(request,authentication)")
.and().headers().cacheControl();
}
/**
* 设置公钥
*
* @param resources
* @throws Exception
*/
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.tokenStore(jwtTokenStore());
}
private TokenStore jwtTokenStore() {
JwtTokenStore jwtTokenStore = new JwtTokenStore(accessTokenConverter());
return jwtTokenStore;
}
@Bean // 放在ioc容器的
public JwtAccessTokenConverter accessTokenConverter() {
//resource 验证token(公钥) authorization 产生 token (私钥)
JwtAccessTokenConverter tokenConverter = new JwtAccessTokenConverter();
String s = null;
try {
ClassPathResource classPathResource = new ClassPathResource("coinexchange.txt");
byte[] bytes = FileCopyUtils.copyToByteArray(classPathResource.getInputStream());
s = new String(bytes, "UTF-8");
} catch (Exception e) {
e.printStackTrace();
}
tokenConverter.setVerifierKey(s);
return tokenConverter;
}
@Component
public class AuthService {
public boolean canAccess(HttpServletRequest request, Authentication authentication) {
Object principal = authentication.getPrincipal();
if (principal == null) {
return false;
}
if (authentication instanceof AnonymousAuthenticationToken) {
//check if this uri can be access by anonymous
//return
}
Set<String> roles = authentication.getAuthorities()
.stream()
.map(e -> e.getAuthority())
.collect(Collectors.toSet());
//获取当前的 url
String uri = request.getRequestURI();
//System.out.println("DynamicPermission principal = " + principal);
if(principal instanceof UserDetails) {
String username = ((UserDetails) principal).getUsername();
//todo 通过 username 去数据库查到该用户下的所有权限
//用户下的所有权限 是否包含当前 uri ,是的话就返回true,否则的话抛出异常,无权限
}
return true;
}
}
}
其他
如果一个调用内部又需要 rpc 调用,rpc 调用中也需要拿到当前用户信息怎么办?
1、向下传递 token
package com.bjsxt.config.feign;
import com.bjsxt.constant.Constants;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
@Slf4j
public class OAuth2FeignConfig implements RequestInterceptor {
/**
* Called for every request. Add data using methods on the supplied {@link RequestTemplate}.
*
* @param template
*/
@Override
public void apply(RequestTemplate template) {
// 1 我们可以从request的上下文环境里面获取token
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
String header = null ;
if (requestAttributes == null) {
// log.info("没有请求的上下文,故无法进行token的传递");
header = "bearer "+ Constants.INSIDE_TOKEN ;
}else{
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
header = request.getHeader(HttpHeaders.AUTHORIZATION); // 获取我们请求上下文的头里面的AUTHORIZATION
}
if (!StringUtils.isEmpty(header)) {
template.header(HttpHeaders.AUTHORIZATION, header);
// log.info("本次token传递成功,token的值为:{}", header);
}
}
}
2、可以在网关解析 token 后向下传递 user 信息 就不用再解析 token 了
/**
* 将登录用户的JWT转化成用户信息的全局过滤器
* Created by macro on 2020/6/17.
*/
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
private static Logger LOGGER = LoggerFactory.getLogger(AuthGlobalFilter.class);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
if (StrUtil.isEmpty(token)) {
return chain.filter(exchange);
}
try {
//从token中解析用户信息并设置到Header中去
String realToken = token.replace("Bearer ", "");
JWSObject jwsObject = JWSObject.parse(realToken);
String userStr = jwsObject.getPayload().toString();
LOGGER.info("AuthGlobalFilter.filter() user:{}",userStr);
ServerHttpRequest request = exchange.getRequest().mutate().header("user", userStr).build();
exchange = exchange.mutate().request(request).build();
} catch (ParseException e) {
e.printStackTrace();
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return 0;
}
}
有人问了,这种情况如果有安全要求怎么办,可以在公共的网关或者公共的一个包中添加一个判断 token 是否有效的逻辑就可以了
package com.bjsxt.filter;
import com.alibaba.fastjson.JSONObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.Set;
@Component
public class JwtCheckFilter implements GlobalFilter, Ordered {
@Autowired
private StringRedisTemplate redisTemplate ;
@Value("${no.require.urls:/admin/login,/user/gt/register,/user/login,/user/users/register,/user/sms/sendTo,/user/users/setPassword}")
private Set<String> noRequireTokenUris ;
/**
* 过滤器拦截到用户的请求后做啥
* @param exchange
* @param chain
* @return
*/
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1 : 该接口是否需要token 才能访问
if(!isRequireToken(exchange)){
return chain.filter(exchange) ;// 不需要token ,直接放行
}
// 2: 取出用户的token
String token = getUserToken(exchange) ;
// 3 判断用户的token 是否有效
if(StringUtils.isEmpty(token)){
return buildeNoAuthorizationResult(exchange) ;
}
Boolean hasKey = redisTemplate.hasKey(token);
if(hasKey!=null && hasKey){
return chain.filter(exchange) ;// token有效 ,直接放行
}
return buildeNoAuthorizationResult(exchange) ;
}
/**
* 给用户响应一个没有token的错误
* @param exchange
* @return
*/
private Mono<Void> buildeNoAuthorizationResult(ServerWebExchange exchange) {
ServerHttpResponse response = exchange.getResponse();
response.getHeaders().set("Content-Type","application/json");
response.setStatusCode(HttpStatus.UNAUTHORIZED) ;
JSONObject jsonObject = new JSONObject();
jsonObject.put("error","NoAuthorization") ;
jsonObject.put("errorMsg","Token is Null or Error") ;
DataBuffer wrap = response.bufferFactory().wrap(jsonObject.toJSONString().getBytes());
return response.writeWith(Flux.just(wrap)) ;
}
/**
* 从 请求头里面获取用户的token
* @param exchange
* @return
*/
private String getUserToken(ServerWebExchange exchange) {
String token = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
return token ==null ? null : token.replace("bearer ","") ;
}
/**
* 判断该 接口是否需要token
* @param exchange
* @return
*/
private boolean isRequireToken(ServerWebExchange exchange) {
String path = exchange.getRequest().getURI().getPath();
if(noRequireTokenUris.contains(path)){
return false ; // 不需要token
}
if(path.contains("/kline/")){
return false ;
}
return Boolean.TRUE ;
}
/**
* 拦截器的顺序
* @return
*/
@Override
public int getOrder() {
return 0;
}
}