单点登录系统主要解决统一认证授权的问题,在多个应用系统中,只需要登录一次,就可以访问其他相互信任的应用系统。
想要完成单点登录的效果,必须先统一管理用户信息,其他应用系统必须配合完成改造和对接。
较大型的企业,往往存在多套应用系统。各个应用系统都是在企业发展的某个阶段,因业务发展的需求,开发研制而成。每套系统都会有一套自己的用户体系,需要终端用户注册、登录后才能使用。
随着企业的发展,用到的系统随之增多,用户在操作不同的系统时,需要多次登录,而且每个系统的账号都不一样,这对于用户来说很不方便。
于是,设计一套统一的登录认证系统,避免不必要的反复登录。减轻用户操作负担,提高效率,在企业的发展进程中,显得越来越重要。
OAuth(开放授权)是一个开放标准,允许用户授权第三方移动应用访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方移动应用或分享他们数据的所有内容。
OAuth 2.0是OAuth协议的下一版本,但不向后兼容OAuth 1.0。 OAuth 2.0关注客户端开发者的简易性,同时为Web应用,桌面应用和手机,和起居室设备提供专门的认证流程。
上文提到,为了实现SSO,各个应用系统需要配合对接单点登录系统。OAuth2.0提供了一套简单,通用,可扩展的开放认证授权协议。既可以实现企业内部系统的对接,也可以实现企业与第三方外部系统的认证互通。
鉴于OAuth2.0的开放特性,只要遵守OAuth2.0协议,可以大幅降低系统间对接开发的沟通调试成本。
Spring Security对OAuth的实现,借助SpringSecurity框架在认证授权方面既有的优势,可以让开发者简易地使用OAuth协议。
image.png
package com.codeiy.auth.oauth.config;
import com.codeiy.auth.oauth.filter.AuthenticationProcessingFilter;
import com.codeiy.auth.oauth.filter.TokenAuthenticationFilter;
import com.codeiy.auth.oauth.handler.OAuthFailureHandler;
import com.codeiy.auth.oauth.handler.OAuthSuccessHandler;
import com.codeiy.auth.oauth.handler.SsoLogoutSuccessHandler;
import com.codeiy.auth.oauth.service.UserDetailsServiceImpl;
import com.codeiy.core.constant.AuthConstants;
import com.codeiy.user.client.UserClient;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import javax.servlet.Filter;
/**
* SpringSecurity基础配置
* 声明认证管理器{@link AuthenticationManager},认证阶段的用户身份鉴别使用自定义的{@link UserDetailsService}
* 采用{@link BCryptPasswordEncoder}对登录密码进行加密及编码,{@link BCryptPasswordEncoder}基于随机盐+密钥对密码进行加密,并通过SHA-256算法进行编码
*
* @author [email protected]
*/
@Primary
@Order(90)
@Configuration(proxyBeanMethods = false)
@RequiredArgsConstructor
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {
private final UserClient userClient;
/**
* 基于随机盐+密钥对密码进行加密,并通过SHA-256算法进行编码
*/
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
/**
* 自定义身份认证服务
*/
@Override
@Bean
public UserDetailsService userDetailsService() {
UserDetailsServiceImpl userDetailsService = new UserDetailsServiceImpl();
userDetailsService.setUserClient(userClient);
userDetailsService.setPasswordEncoder(passwordEncoder);
return userDetailsService;
}
/**
* 1. 禁用csrf
* 2. 请求会话采用无状态方式
* 3. 自定义登录登出路径
* 4. 自定义登录请求参数{@link UsernamePasswordAuthenticationFilter}
*
* @param http http请求安全相关配置
*/
@Override
@SneakyThrows
protected void configure(HttpSecurity http) {
Filter loginFilter = loginFilter();
Filter tokenAuthenticationFilter = tokenAuthenticationFilter();
http.formLogin()
.loginProcessingUrl(AuthConstants.LOGIN_PROCESSING_URL).permitAll()
.and().csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and().logout().logoutSuccessHandler(new SsoLogoutSuccessHandler())
.and().authorizeRequests()
.antMatchers(AuthConstants.LOGOUT_URL).permitAll()
.anyRequest().authenticated()
// 登录请求参数除了用户名,密码之外,还有验证码等其他参数,通过过滤器自定义认证逻辑
.and().addFilterBefore(loginFilter, UsernamePasswordAuthenticationFilter.class)
// 解决微服务架构无状态请求场景下,如何识别当前请求所属用户,并绑定到SpringSecurity上下文
.addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
/**
* 认证管理器
*/
@Bean
@Override
@SneakyThrows
public AuthenticationManager authenticationManagerBean() {
return super.authenticationManagerBean();
}
/**
* 登录密码编码工具
*/
@Bean
public PasswordEncoder passwordEncoder() {
return passwordEncoder;
}
/**
* 自定义登录认证过滤器,满足登录请求参数个性化,多样化需求
*/
private Filter loginFilter() throws Exception {
AuthenticationProcessingFilter loginFilter = new AuthenticationProcessingFilter();
loginFilter.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher(AuthConstants.LOGIN_PROCESSING_URL, AuthConstants.METHOD_POST));
loginFilter.setAuthenticationSuccessHandler(new OAuthSuccessHandler());
loginFilter.setAuthenticationFailureHandler(new OAuthFailureHandler());
loginFilter.setAuthenticationManager(authenticationManager());
return loginFilter;
}
/**
* token解析过滤器,解决微服务无状态场景下,Spring Security OAuth无法在Session中获取用户认证信息的问题
*/
private Filter tokenAuthenticationFilter() {
TokenAuthenticationFilter tokenAuthenticationFilter = new TokenAuthenticationFilter();
return tokenAuthenticationFilter;
}
}
package com.codeiy.auth.oauth.config;
import com.codeiy.auth.oauth.service.AuthenticationCodeServiceImpl;
import com.codeiy.auth.oauth.service.ClientDetailsServiceImpl;
import com.codeiy.core.constant.AuthConstants;
import com.codeiy.user.client.AppClient;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.ClientDetailsService;
import org.springframework.security.oauth2.provider.OAuth2RequestFactory;
import org.springframework.security.oauth2.provider.code.AuthorizationCodeServices;
import org.springframework.security.oauth2.provider.implicit.ImplicitTokenGranter;
import org.springframework.security.oauth2.provider.request.DefaultOAuth2RequestFactory;
import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;
/**
* 通过注解{@code @EnableAuthorizationServer}声明认证服务器
* 基于{@link ClientDetailsServiceImpl}自定义应用系统信息管理
* 基于Redis存储OAuth协议的AccessToken
* 声明OAuth2.0简化模式AccessToken生成规则
* 自定义Auth2.0协议确认授权以及认证请求链接
*/
@RequiredArgsConstructor
@EnableAuthorizationServer
@Configuration(proxyBeanMethods = false)
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
private final AppClient appClient;
private final AuthenticationManager authenticationManager;
private final RedisConnectionFactory redisConnectionFactory;
private final PasswordEncoder passwordEncoder;
private ClientDetailsServiceImpl clientDetailsService;
private RedisTokenStore redisTokenStore;
private AuthorizationServerTokenServices tokenServices;
private OAuth2RequestFactory oAuth2RequestFactory;
private ImplicitTokenGranter implicitTokenGranter;
private AuthorizationCodeServices authorizationCodeServices;
/**
* 基于Redis存储OAuth协议的AccessToken
*/
@Bean
public TokenStore tokenStore() {
if (redisTokenStore == null) {
redisTokenStore = new RedisTokenStore(redisConnectionFactory);
redisTokenStore.setPrefix(AuthConstants.OAUTH_PREFIX);
}
return redisTokenStore;
}
/**
* 令牌权限范围配置,使用默认的{@link DefaultOAuth2RequestFactory},可匹配到用户的角色。
*/
@Bean
public OAuth2RequestFactory oAuth2RequestFactory() {
if (oAuth2RequestFactory == null) {
oAuth2RequestFactory = new DefaultOAuth2RequestFactory(clientDetailsService());
}
return oAuth2RequestFactory;
}
/**
* OAuth2.0简化模式AccessToken生成规则
*/
@Bean
public ImplicitTokenGranter implicitTokenGranter() {
if (implicitTokenGranter == null) {
implicitTokenGranter = new ImplicitTokenGranter(tokenServices(), clientDetailsService(), oAuth2RequestFactory());
}
return implicitTokenGranter;
}
/**
* OAuth2.0授权码模式,自定义授权码的存储方式
*/
@Bean
public AuthorizationCodeServices authorizationCodeServices() {
if (authorizationCodeServices == null) {
authorizationCodeServices = new AuthenticationCodeServiceImpl(redisConnectionFactory);
}
return authorizationCodeServices;
}
/**
* 自定义应用系统信息管理
*/
private ClientDetailsService clientDetailsService() {
if (clientDetailsService == null) {
clientDetailsService = new ClientDetailsServiceImpl();
clientDetailsService.setPasswordEncoder(passwordEncoder);
clientDetailsService.setAppClient(appClient);
}
return clientDetailsService;
}
/**
* 声明AccessToken管理服务
*/
private AuthorizationServerTokenServices tokenServices() {
if (tokenServices == null) {
tokenServices = new DefaultTokenServices();
}
return tokenServices;
}
/**
* 配置应用系统管理,基于{@link ClientDetailsServiceImpl}自定义应用系统信息管理
*/
@Override
@SneakyThrows
public void configure(ClientDetailsServiceConfigurer clients) {
clients.withClientDetails(clientDetailsService());
}
/**
* 配置获取AccessToken请求的访问权限
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) {
oauthServer.tokenKeyAccess("permitAll()").allowFormAuthenticationForClients().checkTokenAccess("permitAll()");
}
/**
* 自定义Auth2.0协议确认授权以及认证请求链接
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints
.authenticationManager(authenticationManager)
.authorizationCodeServices(authorizationCodeServices())
.tokenServices(tokenServices())
.allowedTokenEndpointRequestMethods(HttpMethod.POST)
.pathMapping("/oauth/confirm_access", "/oauth/confirmAccess")
.pathMapping("/oauth/authorize", "/oauth/customAuthorize")
;
}
}
package com.codeiy.auth.oauth.endpoint;
import cn.hutool.core.util.StrUtil;
import com.codeiy.core.util.R;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.common.exceptions.*;
import org.springframework.security.oauth2.common.util.OAuth2Utils;
import org.springframework.security.oauth2.provider.*;
import org.springframework.security.oauth2.provider.code.AuthorizationCodeServices;
import org.springframework.security.oauth2.provider.endpoint.DefaultRedirectResolver;
import org.springframework.security.oauth2.provider.endpoint.RedirectResolver;
import org.springframework.security.oauth2.provider.implicit.ImplicitTokenGranter;
import org.springframework.security.oauth2.provider.implicit.ImplicitTokenRequest;
import org.springframework.security.oauth2.provider.request.DefaultOAuth2RequestValidator;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.view.RedirectView;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
import javax.servlet.http.HttpServletRequest;
import java.net.URI;
import java.util.*;
/**
* OAuth授权自定义端点
*/
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/oauth")
public class AuthEndpoint {
private final ClientDetailsService clientDetailsService;
private final ImplicitTokenGranter implicitTokenGranter;
private final AuthorizationCodeServices authorizationCodeServices;
private final OAuth2RequestFactory oAuth2RequestFactory;
private RedirectResolver redirectResolver = new DefaultRedirectResolver();
private OAuth2RequestValidator oauth2RequestValidator = new DefaultOAuth2RequestValidator();
/**
* 授权确认页面
*
* @return
*/
@GetMapping("/confirmAccess")
public R confirmAccess(HttpServletRequest request) {
String clientId = request.getParameter("clientId");
if (StrUtil.isBlank(clientId)) {
return R.failed("clientId不能为空");
}
ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId);
if (clientDetails == null) {
return R.failed("根据clientId查无数据");
}
return R.ok(clientDetails);
}
/**
* 自定义确认授权
*
* @return
*/
@RequestMapping("/customAuthorize")
public R customAuthorize(HttpServletRequest request, @RequestParam Map parameters) {
AuthorizationRequest authorizationRequest = oAuth2RequestFactory.createAuthorizationRequest(parameters);
Set responseTypes = authorizationRequest.getResponseTypes();
if (!responseTypes.contains("token") && !responseTypes.contains("code")) {
throw new UnsupportedResponseTypeException("Unsupported response types: " + responseTypes);
}
if (authorizationRequest.getClientId() == null) {
throw new InvalidClientException("A client id must be provided");
}
Authentication principal = SecurityContextHolder.getContext().getAuthentication();
if (principal == null || !principal.isAuthenticated()) {
throw new InsufficientAuthenticationException(
"User must be authenticated with Spring Security before authorization can be completed.");
}
ClientDetails client = clientDetailsService.loadClientByClientId(authorizationRequest.getClientId());
// The resolved redirect URI is either the redirect_uri from the parameters or the one from
// clientDetails. Either way we need to store it on the AuthorizationRequest.
String redirectUriParameter = authorizationRequest.getRequestParameters().get(OAuth2Utils.REDIRECT_URI);
String resolvedRedirect = redirectResolver.resolveRedirect(redirectUriParameter, client);
if (!StringUtils.hasText(resolvedRedirect)) {
throw new RedirectMismatchException(
"A redirectUri must be either supplied or preconfigured in the ClientDetails");
}
authorizationRequest.setRedirectUri(resolvedRedirect);
// We intentionally only validate the parameters requested by the client (ignoring any data that may have
// been added to the request by the manager).
oauth2RequestValidator.validateScope(authorizationRequest, client);
authorizationRequest.setApproved("true".equals(parameters.get("user_oauth_approval")));
// Validation is all done, so we can check for auto approval...
if (authorizationRequest.isApproved()) {
if (responseTypes.contains("token")) {
ModelAndView mv = getImplicitGrantResponse(authorizationRequest);
}
if (responseTypes.contains("code")) {
String url = getSuccessfulRedirect(authorizationRequest, generateCode(authorizationRequest, principal));
log.info(url);
}
}
String clientId = request.getParameter("clientId");
if (StrUtil.isBlank(clientId)) {
return R.failed("clientId不能为空");
}
ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId);
if (clientDetails == null) {
return R.failed("根据clientId查无数据");
}
return R.ok(clientDetails);
}
private String getSuccessfulRedirect(AuthorizationRequest authorizationRequest, String authorizationCode) {
if (authorizationCode == null) {
throw new IllegalStateException("No authorization code found in the current request scope.");
}
Map query = new LinkedHashMap();
query.put("code", authorizationCode);
String state = authorizationRequest.getState();
if (state != null) {
query.put("state", state);
}
return append(authorizationRequest.getRedirectUri(), query, null, false);
}
private String generateCode(AuthorizationRequest authorizationRequest, Authentication authentication)
throws AuthenticationException {
try {
OAuth2Request storedOAuth2Request = oAuth2RequestFactory.createOAuth2Request(authorizationRequest);
OAuth2Authentication combinedAuth = new OAuth2Authentication(storedOAuth2Request, authentication);
String code = authorizationCodeServices.createAuthorizationCode(combinedAuth);
return code;
} catch (OAuth2Exception e) {
if (authorizationRequest.getState() != null) {
e.addAdditionalInformation("state", authorizationRequest.getState());
}
throw e;
}
}
private ModelAndView getImplicitGrantResponse(AuthorizationRequest authorizationRequest) {
try {
TokenRequest tokenRequest = oAuth2RequestFactory.createTokenRequest(authorizationRequest, "implicit");
OAuth2Request storedOAuth2Request = oAuth2RequestFactory.createOAuth2Request(authorizationRequest);
OAuth2AccessToken accessToken = getAccessTokenForImplicitGrant(tokenRequest, storedOAuth2Request);
if (accessToken == null) {
throw new UnsupportedResponseTypeException("Unsupported response type: token");
}
return new ModelAndView(new RedirectView(appendAccessToken(authorizationRequest, accessToken), false, true,
false));
} catch (OAuth2Exception e) {
return new ModelAndView(new RedirectView(getUnsuccessfulRedirect(authorizationRequest, e, true), false,
true, false));
}
}
private String appendAccessToken(AuthorizationRequest authorizationRequest, OAuth2AccessToken accessToken) {
Map vars = new LinkedHashMap();
Map keys = new HashMap();
if (accessToken == null) {
throw new InvalidRequestException("An implicit grant could not be made");
}
vars.put("access_token", accessToken.getValue());
vars.put("token_type", accessToken.getTokenType());
String state = authorizationRequest.getState();
if (state != null) {
vars.put("state", state);
}
Date expiration = accessToken.getExpiration();
if (expiration != null) {
long expires_in = (expiration.getTime() - System.currentTimeMillis()) / 1000;
vars.put("expires_in", expires_in);
}
String originalScope = authorizationRequest.getRequestParameters().get(OAuth2Utils.SCOPE);
if (originalScope == null || !OAuth2Utils.parseParameterList(originalScope).equals(accessToken.getScope())) {
vars.put("scope", OAuth2Utils.formatParameterList(accessToken.getScope()));
}
Map additionalInformation = accessToken.getAdditionalInformation();
for (String key : additionalInformation.keySet()) {
Object value = additionalInformation.get(key);
if (value != null) {
keys.put("extra_" + key, key);
vars.put("extra_" + key, value);
}
}
// Do not include the refresh token (even if there is one)
return append(authorizationRequest.getRedirectUri(), vars, keys, true);
}
private String getUnsuccessfulRedirect(AuthorizationRequest authorizationRequest, OAuth2Exception failure,
boolean fragment) {
if (authorizationRequest == null || authorizationRequest.getRedirectUri() == null) {
// we have no redirect for the user. very sad.
throw new UnapprovedClientAuthenticationException("Authorization failure, and no redirect URI.", failure);
}
Map query = new LinkedHashMap();
query.put("error", failure.getOAuth2ErrorCode());
query.put("error_description", failure.getMessage());
if (authorizationRequest.getState() != null) {
query.put("state", authorizationRequest.getState());
}
if (failure.getAdditionalInformation() != null) {
for (Map.Entry additionalInfo : failure.getAdditionalInformation().entrySet()) {
query.put(additionalInfo.getKey(), additionalInfo.getValue());
}
}
return append(authorizationRequest.getRedirectUri(), null, query, fragment);
}
private String append(String base, Map query, Map keys, boolean fragment) {
UriComponentsBuilder template = UriComponentsBuilder.newInstance();
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(base);
URI redirectUri;
try {
// assume it's encoded to start with (if it came in over the wire)
redirectUri = builder.build(true).toUri();
} catch (Exception e) {
// ... but allow client registrations to contain hard-coded non-encoded values
redirectUri = builder.build().toUri();
builder = UriComponentsBuilder.fromUri(redirectUri);
}
template.scheme(redirectUri.getScheme()).port(redirectUri.getPort()).host(redirectUri.getHost())
.userInfo(redirectUri.getUserInfo()).path(redirectUri.getPath());
if (fragment) {
StringBuilder values = new StringBuilder();
if (redirectUri.getFragment() != null) {
String append = redirectUri.getFragment();
values.append(append);
}
for (String key : query.keySet()) {
if (values.length() > 0) {
values.append("&");
}
String name = key;
if (keys != null && keys.containsKey(key)) {
name = keys.get(key);
}
values.append(name + "={" + key + "}");
}
if (values.length() > 0) {
template.fragment(values.toString());
}
UriComponents encoded = template.build().expand(query).encode();
builder.fragment(encoded.getFragment());
} else {
for (String key : query.keySet()) {
String name = key;
if (keys != null && keys.containsKey(key)) {
name = keys.get(key);
}
template.queryParam(name, "{" + key + "}");
}
template.fragment(redirectUri.getFragment());
UriComponents encoded = template.build().expand(query).encode();
builder.query(encoded.getQuery());
}
return builder.build().toUriString();
}
private synchronized OAuth2AccessToken getAccessTokenForImplicitGrant(TokenRequest tokenRequest,
OAuth2Request storedOAuth2Request) {
return implicitTokenGranter.grant("implicit", new ImplicitTokenRequest(tokenRequest, storedOAuth2Request));
}
}
欢迎交流与分享经验! 可在微信搜一搜“在代码的世界里自由自在”或扫下方二维码关注我的公众号