名词定义
在理解OAuth 2.0作用之前,需要了解几个专用名词:
(1)Third-party application:第三方应用程序,简称"客户端"(client);
(2)HTTP service:HTTP服务提供商,简称服务端;
(3)Resource Owner:资源所有者,简称"用户"(user);
(4)User Agent:用户代理,是指浏览器;
(5)Authorization server:认证服务器,即服务端专门用来处理认证的服务器;
(6)Resource server:资源服务器,即服务端存放用户生成的资源的服务器。它与认证服务器,可以是同一台服务器,也可以是不同的服务器。
知道了上面这些名词,就不难理解,OAuth的作用就是让客户端安全可控地获取用户的授权,与服务端进行互动。
OAuth2基本思路
OAuth在客户端与服务端之间,设置了一个授权层(authorization layer)。客户端不能直接登录服务端,只能通过登录授权层获取服务端资源,以此将用户与客户端区分开来。客户端登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期。
客户端登录授权层以后,服务端根据令牌的权限范围和有效期,向客户端开放用户可访问的资源。
OAuth 2.0的运行流程如下图所示:
(1)用户打开客户端请求用户给予授权;
(2)用户返回客户端授权;
(3)客户端使用获得的授权,向认证服务器请求token;
(4)认证服务器对客户端进行认证以后,同意给予认证token;
(5)客户端使用token,向资源服务器请求获取资源;
(6)资源服务器确认token,并同意向客户端开放资源。
springboot集成OAuth2.0配置使用
A.pom.xml文件中添加OAuth2支持(springboot2.0已将oauth2.0与security整合在一起,只需添加一下配置即可):
B.授权服务器配置:自定义OAuth2客户端认证与授权;
/**
* 授权服务器配置
*/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
/**
* 认证管理器
* @see SecurityConfig 的authenticationManagerBean()
*/
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisConnectionFactory redisConnectionFactory;
/**
* 使用jwt或者redis<br>
* 默认redis
*/
@Value("${access_token.store-jwt:false}")
private boolean storeWithJwt;
/**
* 登陆后返回的json数据是否追加当前用户信息
* 默认false
*/
@Value("${access_token.add-userinfo:false}")
private boolean addUserInfo;
@Autowired
private RedisAuthorizationCodeServices redisAuthorizationCodeServices;
@Autowired
private RedisClientDetailsService redisClientDetailsService;
/**
* 令牌存储
*/
@Bean
public TokenStore tokenStore() {
if (storeWithJwt) {
return new JwtTokenStore(accessTokenConverter());
}
RedisTokenStore redisTokenStore = new RedisTokenStore(redisConnectionFactory);
// 自定义获取Authkey(implements AuthenticationKeyGenerator)
// 解决同一username每次登陆access_token都相同的问题 redisTokenStore.setAuthenticationKeyGenerator(new RandomAuthenticationKeyGenerator());
return redisTokenStore;
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.authenticationManager(this.authenticationManager);
endpoints.tokenStore(tokenStore());
// 授权码模式下,code存储
// endpoints.authorizationCodeServices(new JdbcAuthorizationCodeServices(dataSource)); endpoints.authorizationCodeServices(redisAuthorizationCodeServices);
if (storeWithJwt) {
endpoints.accessTokenConverter(accessTokenConverter());
} else {
// 将当前用户信息追加到登陆后返回数据里
endpoints.tokenEnhancer((accessToken, authentication) -> {
addLoginUserInfo(accessToken, authentication);
return accessToken;
});
}
}
/**
* 将当前用户信息追加到登陆后返回的json数据里<br>
* 通过参数access_token.add-userinfo控制<br>
*
* @param accessToken
* @param authentication
*/
private void addLoginUserInfo(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
if (!addUserInfo) {
return;
}
if (accessToken instanceof DefaultOAuth2AccessToken) {
DefaultOAuth2AccessToken defaultOAuth2AccessToken = (DefaultOAuth2AccessToken) accessToken;
Authentication userAuthentication = authentication.getUserAuthentication();
Object principal = userAuthentication.getPrincipal();
if (principal instanceof LoginAppUser) {
LoginAppUser loginUser = (LoginAppUser) principal;
Map<String, Object> map = new HashMap<>(defaultOAuth2AccessToken.getAdditionalInformation()); // 旧的附加参数
map.put("loginUser", loginUser); // 追加当前登陆用户 defaultOAuth2AccessToken.setAdditionalInformation(map);
}
}
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.allowFormAuthenticationForClients(); // 允许表单形式的认证
}
/**
* 我们将client信息存储到oauth_client_details表里<br>
* 并将数据缓存到redis
* @param clients
* @throws Exception
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
// 这里优化一下,详细看下redisClientDetailsService这个实现类
clients.withClientDetails(redisClientDetailsService) redisClientDetailsService.loadAllClientToCache();
}
@Autowired
public UserDetailsService userDetailsService;
/**
* jwt签名key,可随意指定<br>
* 如配置文件里不设置的话,冒号后面的是默认值
*/
@Value("${access_token.jwt-signing-key:lihua}")
private String signingKey;
/**
* Jwt资源令牌转换器<br>
* 参数access_token.store-jwt为true时用到
*
* @return accessTokenConverter
*/
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter() {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
OAuth2AccessToken oAuth2AccessToken = super.enhance(accessToken, authentication);
addLoginUserInfo(oAuth2AccessToken, authentication); // 将当前用户信息追加到登陆后返回数据里
return oAuth2AccessToken;
}
};
DefaultAccessTokenConverter defaultAccessTokenConverter = (DefaultAccessTokenConverter) jwtAccessTokenConverter
.getAccessTokenConverter();
DefaultUserAuthenticationConverter userAuthenticationConverter = new DefaultUserAuthenticationConverter();
userAuthenticationConverter.setUserDetailsService(userDetailsService);
defaultAccessTokenConverter.setUserTokenConverter(userAuthenticationConverter);
// 这里务必设置一个,否则多台认证中心的话,一旦使用jwt方式,access_token将解析错误
jwtAccessTokenConverter.setSigningKey(signingKey);
return jwtAccessTokenConverter;
}
}
C.资源服务配置:设置受保护的资源和可访问的资源
@EnableResourceServer
@EnableWebSecurity
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable().exceptionHandling()
.authenticationEntryPoint(
(request, response, authException)
/*-> response.sendRedirect("/hello/get"))*/
-> response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "no auth"))
.and().authorizeRequests()
.antMatchers(PermitAllUrl.permitAllUrl("/hello/**","/login","/oauth/token")).permitAll() // 放开权限的url ,"/hello/**"
.anyRequest().authenticated().and().httpBasic();
}
/**
* 自定义密码加密机制
*/
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
D.自定义RedisClientDetailsService类
/**
* 将oauth_client_details表数据缓存到redis,毕竟该表改动非常小,而且数据很少,这里做个缓存优化
* 如果有通过界面修改client的需求的话,不要JdbcClientDetailsService了,请用该类,否则redis里有缓存
* 如果手动修改了该表的数据,请注意清除redis缓存,是hash结构,key是client_details
*/
@Slf4j
@Service
public class RedisClientDetailsService extends JdbcClientDetailsService {
@Autowired
private CacheManager cacheManager;
public RedisClientDetailsService(DataSource dataSource) {
super(dataSource);
}
/**
* 缓存client的redis key,这里是hash结构存储
*/
private static final String CACHE_CLIENT_KEY = "client_details";
@Override
public ClientDetails loadClientByClientId(String clientId) throws InvalidClientException {
ClientDetails clientDetails = null;
// 先从redis获取
Cache cache = cacheManager.getCache(CACHE_CLIENT_KEY);
ValueWrapper valueWrapper = cache.get(clientId);
if (valueWrapper != null) {
clientDetails = (ClientDetails) valueWrapper.get();
} else {
clientDetails = super.loadClientByClientId(clientId);
if (clientDetails != null) {// 写入redis缓存
cache.put(clientId, clientDetails);
log.info("缓存clientId:{},{}", clientId, clientDetails);
}
}
return clientDetails;
}
/**
* 缓存client并返回client
* @param clientId
*/
private ClientDetails cacheAndGetClient(String clientId) {
// 从数据库读取
ClientDetails clientDetails = super.loadClientByClientId(clientId);
if (clientDetails != null) {// 写入redis缓存
Cache cache = cacheManager.getCache(CACHE_CLIENT_KEY);
cache.put(clientId, clientDetails);
log.info("缓存clientId:{},{}", clientId, clientDetails);
}
return clientDetails;
}
@Override
public void updateClientDetails(ClientDetails clientDetails) throws NoSuchClientException {
super.updateClientDetails(clientDetails);
cacheAndGetClient(clientDetails.getClientId());
}
@Override
public void updateClientSecret(String clientId, String secret) throws NoSuchClientException {
super.updateClientSecret(clientId, secret);
cacheAndGetClient(clientId);
}
@Override
public void removeClientDetails(String clientId) throws NoSuchClientException {
super.removeClientDetails(clientId);
removeRedisCache(clientId);
}
/**
* 删除redis缓存
*
* @param clientId
*/
private void removeRedisCache(String clientId) {
Cache cache = cacheManager.getCache(CACHE_CLIENT_KEY);
cache.evict(clientId);
}
/**
* 将oauth_client_details全表刷入redis
*/
public void loadAllClientToCache() {
Cache cache = cacheManager.getCache(CACHE_CLIENT_KEY);
//log.info("将oauth_client_details全表刷入redis");
List<ClientDetails> list = super.listClientDetails();
if (CollectionUtils.isEmpty(list)) {
log.error("oauth_client_details表数据为空,请检查");
return;
}
list.parallelStream().forEach(client -> {
cache.put(client.getClientId(), client);
});
}
}
E:设置登录拦截
public class LoginFilter implements Filter {
@Override
public void destroy() {
System.out.println("--------------过滤器销毁------------");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
String code =request.getParameter(CommonController.VALIDATE_CODE);
HttpSession session = ((HttpServletRequest)request).getSession(true);
String ocode = (String) session.getAttribute(CommonController.VALIDATE_CODE);
if(ocode==null || code ==null || !StringUtils.equalsIgnoreCase(ocode, code)) {
response.setCharacterEncoding("utf-8");
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(JsonUtils.objectToJsonWhitI18N( Result.BAD_REQUEST(ContentConstant.VCODE_ERROR)));
return;
}
//request.gets
//request.getSession()
HashMap<String, Object> params = new HashMap<>();
params.put("grant_type", new String[] {"password"});
params.put("client_id", new String[] {"system"});
params.put("client_secret", new String[] {"system"});
params.put("scope", new String[] {"app"});
ParameterRequestWrapper wrapRequest=new ParameterRequestWrapper((HttpServletRequest)request,params);
chain.doFilter(wrapRequest, response);
}
@Override
public void init(FilterConfig arg0) throws ServletException {
System.out.println("--------------过滤器初始化------------");
}
}
F:使用post访问链接:http://ip:port/oauth/token
参数:
grant_type=password(密码模式)
client_id=system(自定义)
client_secret=system(自定义)
scope=app(自定义)
username=数据库中设置的自定义用户名
password=数据库中设置的自定义密码
访问成功,则可获取如下结果:
注:
access_token:表示访问令牌,必选项;
token_type:表示令牌类型,该值大小写不敏感,必选项,可以是bearer类型或mac类型;
refresh_token:表示更新令牌,用来获取下一次的访问令牌,可选项;
expires_in:表示过期时间,单位为秒。如果省略该参数,必须其他方式设置过期时间;
scope:表示权限范围,如果与客户端申请的范围一致,此项可省略。