之前的文章中我们使用SpringSocial实现了第三方授权登录,本篇文章我们将使用SpringSecurityOAuth来开发认证服务器和资源服务器。
SpringSecurityOAuth其实已经帮我们默认实现了以下一些东西:
- 认证服务器
- 4种授权模式的默认实现
- Token的生成存储
- 资源服务器
-
OAuthAuthenticationProcessingFilter(拦截用户请求中的token并从认证服务器中寻找对应的用户信息)
如下图所示:
-
一个简单的实现
1.认证服务器
1.1@EnableAuthorizationServer
@Configuration
@EnableAuthorizationServer
public class ImoocAuthorizationServerConfig {
}
有了这个注解表示我们的认证服务器已经默认实现了。启动一下我们会看到如下信息:
/oauth/authorize表示引导用户跳转去授权的路径,/oauth/token表示通过授权码获取token的路径。按照OAuth2的协议规范,我们去跳转授权的时候需要用这样的路径去访问:http://localhost:8060/oauth/authorize?response_type=code&client_id=imooc&redirect_uri=http://www.jianshu.com&scope=all
这些参数什么意思呢?其实理解他们并不难,这里不建议大家去死记硬背,而是要把自己想象一下授权的时候需要什么东西?
1.首先我们要知道哪一个应用再授权?比如我们要知道是简书需要授权还是慕课需要授权?client_id就是服务提供商给每个应用分配的id,所以请求的时候需要这个参数。这个clientId可以在应用启动的时候看到如下图所示:
2.第三方应用在请求我的哪一个用户授权?所以我们必须要得到用户名。但是请求参数中没有用户名啊?这不是在忽悠吗~~如下图所示:
我们访问的/oauth/authorize的弹出框就需要我们填写这个东东
3.给你哪些授权?scope=all表示全部权限拿到。这个参数带的值是由服务提供商定义的,所以不要乱填写~
1.2application.properties
security.oauth2.client.clientId = imooc
security.oauth2.client.clientSecret = imoocsecret
上图中我们看到的clientId就是在这里配置的
1.3配置ROLE_USER角色
@Component
public class DemoUserDetailsService implements UserDetailsService, SocialUserDetailsService {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private PasswordEncoder passwordEncoder;
/*
* (non-Javadoc)
*
* @see org.springframework.security.core.userdetails.UserDetailsService#
* loadUserByUsername(java.lang.String)
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
logger.info("表单登录用户名:" + username);
return buildUser(username);
}
@Override
public SocialUserDetails loadUserByUserId(String userId) throws UsernameNotFoundException {
logger.info("设计登录用户Id:" + userId);
return buildUser(userId);
}
private SocialUserDetails buildUser(String userId) {
// 根据用户名查找用户信息
//根据查找到的用户信息判断用户是否被冻结
String password = passwordEncoder.encode("123456");
logger.info("数据库密码是:"+password);
return new SocialUser(userId, password,
true, true, true, true,
AuthorityUtils.commaSeparatedStringToAuthorityList("admin,ROLE_USER"));
}
}
如果不配置ROLE_USER,即使我们输入了正确的用户名和密码也会403拒绝。
1.4登录并授权
我们发现这样我们就获取到了code~~
1.5获取token
这里我们必须要发起post请求
我们要在请求头里面包含我们配置的clientId和clientSecret,然后在按照OAuth协议填写好请求参数:
得到的结果如下:
2.资源服务器
2.1@EnableResourceServer
@Configuration
@EnableResourceServer
public class ImoocResourceServerConfig {
}
2.2带着token去访问资源
这里我们一个简单的默认模式就跑完了,但是还是有很多优化的地方~
SpringSecurityOAuth原理解析
图中绿色的方框表示具体的实现类,蓝色的方框表示接口。TokenEndpoint是一个Controller,在第三方应用获取token的时候首先就进入到它,ClientDetailsService用于读取第三方client的信息,我们在刚才的例子中其实演示了,我们会在请求头里面输入clientId和clientSecret,这样就能知道是哪一个应用再授权,然后读取到的client信息会放到ClientDetails里面。TokenRequest封装了ClientDetails,同时也封装了请求中的请求参数信息,封装完成后由一个TokenGranter来处理这个请求,它会根据不同的grantType来进行相应的处理,然后生成两个东西,一个是OAuth2Request,一个是Authentication。OAuth2Request是对ClientDetails和TokenRequest的一个整合。Authentication就是对授权用户信息的一个封装,其实就是UserDetails读取出来的用户信息。最后它们会被组合成一个OAuth2Authentication,这个东西最后会传递给AuthorizationServerTokenServices,然后它来帮助生成OAuth2AccessToken。TokenStore用于存储令牌,获取令牌,令牌刷新等等一些操作,TokenEnhancer可以让我们给Token做一些加强和改造。
这里还是要多说一下,我们看到在获取token的时候,传递了一个scope到后端,有没有同学问,如果说用户授权的时候只给了读的权限,但是我这个第三方应用动点小心思,把这个scope换成写的权限,这样不就成功绕过了吗~看看下图:
生成的tokenRequest其实直接是把scope给清理掉了~~所以这种情况不会发生。
了解了SpringSecurityOAuth的基本原理后,接下来我们就要开始进行改造和优化了。
刚才的原理中我们用的是默认的实现方式,但是如果我们想用自定义的认证方式去获取token该怎么办呢?我们这里需要保留AuthorizationServerTokenServices,然后自己去写登录逻辑处理和AuthenticationSuccessHandler,我们知道AuthenticationSuccessHandler的回调函数中已经包含了Authentication了,但是创建OAuth2Authentication还需要OAuth2Request,这个东西该怎么去获取呢?OAuth2Request由ClientDetails和TokenRequest组成,而ClientDetails是由ClientDetailsService通过ClientId获取到的,ClientId是由前台的请求参数获取到的,另外TokenRequest也是由请求参数组装而来,了解了这个机制后,我们的AuthenticationSuccessHandler就好编写了。
重构用户名密码登录
1. ImoocAuthenticationSuccessHandler重写
@Component("imoocAuthenticationSuccessHandler")
public class ImoocAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private ObjectMapper objectMapper;
@Autowired
private SecurityProperties securityProperties;
@Autowired
private ClientDetailsService clientDetailsService;
@Autowired
private AuthorizationServerTokenServices authorizationServerTokenServices;
/*
* (non-Javadoc)
*
* @see org.springframework.security.web.authentication.
* AuthenticationSuccessHandler#onAuthenticationSuccess(javax.servlet.http.
* HttpServletRequest, javax.servlet.http.HttpServletResponse,
* org.springframework.security.core.Authentication)
*/
@SuppressWarnings("unchecked")
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
logger.info("登录成功");
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Basic ")) {
throw new UnapprovedClientAuthenticationException("请求头中无client信息");
}
String[] tokens = extractAndDecodeHeader(header, request);
assert tokens.length == 2;
String clientId = tokens[0];
String clientSecret = tokens[1];
ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId);
if (clientDetails == null) {
throw new UnapprovedClientAuthenticationException("clientId对应的配置信息不存在:" + clientId);
} else if (!StringUtils.equals(clientDetails.getClientSecret(), clientSecret)) {
throw new UnapprovedClientAuthenticationException("clientSecret不匹配:" + clientId);
}
TokenRequest tokenRequest = new TokenRequest(MapUtils.EMPTY_MAP, clientId, clientDetails.getScope(), "custom");
OAuth2Request oAuth2Request = tokenRequest.createOAuth2Request(clientDetails);
OAuth2Authentication oAuth2Authentication = new OAuth2Authentication(oAuth2Request, authentication);
OAuth2AccessToken token = authorizationServerTokenServices.createAccessToken(oAuth2Authentication);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(token));
}
private String[] extractAndDecodeHeader(String header, HttpServletRequest request) throws IOException {
byte[] base64Token = header.substring(6).getBytes("UTF-8");
byte[] decoded;
try {
decoded = Base64.decode(base64Token);
} catch (IllegalArgumentException e) {
throw new BadCredentialsException("Failed to decode basic authentication token");
}
String token = new String(decoded, "UTF-8");
int delim = token.indexOf(":");
if (delim == -1) {
throw new BadCredentialsException("Invalid basic authentication token");
}
return new String[] { token.substring(0, delim), token.substring(delim + 1) };
}
}
这里我们通过获取请求头中的clientId和ClientSecret构建相应的信息,和源码的实现基本类似,但是重点说一处不同的地方:
TokenRequest tokenRequest = new TokenRequest(MapUtils.EMPTY_MAP, clientId, clientDetails.getScope(), "custom");
这里的第一个参数本来应该是前台传递过来的相关参数,比如用户名密码之类的东西,但是我们现在自定义了,所以直接给了一个空的map不影响,第二个参数就是请求头过来的clientId,第三个参数是直接把用户所有的scope拿过来的,其实现实中我们可以实现的更细,比如说只拿用户的部分授权而不是全部授权,这里相当于是拿了全部的授权,最后一个参数源码中的实现应该是4中授权模式中的一种,但是这里我们是自定义的授权,所以传递了一个"custom"。
1.2ImoocResourceServerConfig
@Configuration
@EnableResourceServer
public class ImoocResourceServerConfig extends ResourceServerConfigurerAdapter {
@Autowired
protected AuthenticationSuccessHandler imoocAuthenticationSuccessHandler;
@Autowired
protected AuthenticationFailureHandler imoocAuthenticationFailureHandler;
@Autowired
private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;
@Autowired
private ValidateCodeSecurityConfig validateCodeSecurityConfig;
@Autowired
private SpringSocialConfigurer imoocSocialSecurityConfig;
@Autowired
private SecurityProperties securityProperties;
@Override
public void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage(SecurityConstants.DEFAULT_UNAUTHENTICATION_URL)
.loginProcessingUrl(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_FORM)
.successHandler(imoocAuthenticationSuccessHandler)
.failureHandler(imoocAuthenticationFailureHandler);
http//.apply(validateCodeSecurityConfig)
// .and()
.apply(smsCodeAuthenticationSecurityConfig)
.and()
.apply(imoocSocialSecurityConfig)
.and()
.authorizeRequests()
.antMatchers(
SecurityConstants.DEFAULT_UNAUTHENTICATION_URL,
SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_MOBILE,
securityProperties.getBrowser().getLoginPage(),
SecurityConstants.DEFAULT_VALIDATE_CODE_URL_PREFIX+"/*",
securityProperties.getBrowser().getSignUpUrl(),
securityProperties.getBrowser().getSession().getSessionInvalidUrl(),
securityProperties.getBrowser().getSignOutUrl(),
"/user/regist")
.permitAll()
.anyRequest()
.authenticated()
.and()
.csrf().disable();
}
}
1.3演示
我们模拟了用户名表单登录,这里成功得到了token,authentication/form这个路径是登录时的跳转路径。登录成功后,successHandler会返回token给客户。
重构短信登录
在之前的短信登录中,短信验证码是存储在session中的,像微信小程序是没法拿到cookie的,即使session中有验证码,也没有任何用处,所以我们需要改造一下:
这里改造很简单,我就不贴代码了,原理就是在header中我们传递一下设备Id,然后在把验证码存储在redis中而不是session中就可以了。
重构社交登录
之前我们获取token什么的都是用户直接和Client打交道,现在我们重构一下社交登录,用户只和App打交道,然后APP和我们后端的Client打交道。如下面2张图所示,一种授权码模式,一种简化模式:
1.定义OpenIdAuthenticationToken
只有两个成员变量,一个opendId和一个providerId,这样我们就可以知道是哪个服务提供商提供的opendId。
public class OpenIdAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
// ~ Instance fields
// ================================================================================================
private final Object principal;
private String providerId;
// ~ Constructors
// ===================================================================================================
/**
* This constructor can be safely used by any code that wishes to create a
* <code>UsernamePasswordAuthenticationToken</code>, as the {@link #isAuthenticated()}
* will return <code>false</code>.
*
*/
public OpenIdAuthenticationToken(String openId, String providerId) {
super(null);
this.principal = openId;
this.providerId = providerId;
setAuthenticated(false);
}
/**
* This constructor should only be used by <code>AuthenticationManager</code> or
* <code>AuthenticationProvider</code> implementations that are satisfied with
* producing a trusted (i.e. {@link #isAuthenticated()} = <code>true</code>)
* authentication token.
*
* @param principal
* @param credentials
* @param authorities
*/
public OpenIdAuthenticationToken(Object principal,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
super.setAuthenticated(true); // must use super, as we override
}
// ~ Methods
// ========================================================================================================
public Object getCredentials() {
return null;
}
public Object getPrincipal() {
return this.principal;
}
public String getProviderId() {
return providerId;
}
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
if (isAuthenticated) {
throw new IllegalArgumentException(
"Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
}
super.setAuthenticated(false);
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
}
}
2.OpenIdAuthenticationFilter
定义好了token,我们需要一个Filter来拦截登录信息,然后把这些信息封装成一个我们自定义的Token(未认证),然后交给Manager,所以,我们这里自定义了一个OpenIdAuthenticationFilter。
public class OpenIdAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
// ~ Static fields/initializers
// =====================================================================================
private String openIdParameter = SecurityConstants.DEFAULT_PARAMETER_NAME_OPENID;
private String providerIdParameter = SecurityConstants.DEFAULT_PARAMETER_NAME_PROVIDERID;
private boolean postOnly = true;
// ~ Constructors
// ===================================================================================================
public OpenIdAuthenticationFilter() {
super(new AntPathRequestMatcher(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_OPENID, "POST"));
}
// ~ Methods
// ========================================================================================================
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String openid = obtainOpenId(request);
String providerId = obtainProviderId(request);
if (openid == null) {
openid = "";
}
if (providerId == null) {
providerId = "";
}
openid = openid.trim();
providerId = providerId.trim();
OpenIdAuthenticationToken authRequest = new OpenIdAuthenticationToken(openid, providerId);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
/**
* 获取openId
*/
protected String obtainOpenId(HttpServletRequest request) {
return request.getParameter(openIdParameter);
}
/**
* 获取提供商id
*/
protected String obtainProviderId(HttpServletRequest request) {
return request.getParameter(providerIdParameter);
}
/**
* Provided so that subclasses may configure what is put into the
* authentication request's details property.
*
* @param request
* that an authentication request is being created for
* @param authRequest
* the authentication request object that should have its details
* set
*/
protected void setDetails(HttpServletRequest request, OpenIdAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}
/**
* Sets the parameter name which will be used to obtain the username from
* the login request.
*
* @param usernameParameter
* the parameter name. Defaults to "username".
*/
public void setOpenIdParameter(String openIdParameter) {
Assert.hasText(openIdParameter, "Username parameter must not be empty or null");
this.openIdParameter = openIdParameter;
}
/**
* Defines whether only HTTP POST requests will be allowed by this filter.
* If set to true, and an authentication request is received which is not a
* POST request, an exception will be raised immediately and authentication
* will not be attempted. The <tt>unsuccessfulAuthentication()</tt> method
* will be called as if handling a failed authentication.
* <p>
* Defaults to <tt>true</tt> but may be overridden by subclasses.
*/
public void setPostOnly(boolean postOnly) {
this.postOnly = postOnly;
}
public final String getOpenIdParameter() {
return openIdParameter;
}
public String getProviderIdParameter() {
return providerIdParameter;
}
public void setProviderIdParameter(String providerIdParameter) {
this.providerIdParameter = providerIdParameter;
}
}
3. 验证Token的Provider
主要步骤是根据未认证的token,然后到usersConnectionRepository中去获取用户的userId,拿到这个userId之后,然后用userDetailService拿到用户信息,在封装成认证后的OpenIdAuthenticationToken。
public class OpenIdAuthenticationProvider implements AuthenticationProvider {
private SocialUserDetailsService userDetailsService;
private UsersConnectionRepository usersConnectionRepository;
/*
* (non-Javadoc)
*
* @see org.springframework.security.authentication.AuthenticationProvider#
* authenticate(org.springframework.security.core.Authentication)
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OpenIdAuthenticationToken authenticationToken = (OpenIdAuthenticationToken) authentication;
Set<String> providerUserIds = new HashSet<>();
providerUserIds.add((String) authenticationToken.getPrincipal());
Set<String> userIds = usersConnectionRepository.findUserIdsConnectedTo(authenticationToken.getProviderId(), providerUserIds);
if(CollectionUtils.isEmpty(userIds) || userIds.size() != 1) {
throw new InternalAuthenticationServiceException("无法获取用户信息");
}
String userId = userIds.iterator().next();
UserDetails user = userDetailsService.loadUserByUserId(userId);
if (user == null) {
throw new InternalAuthenticationServiceException("无法获取用户信息");
}
OpenIdAuthenticationToken authenticationResult = new OpenIdAuthenticationToken(user, user.getAuthorities());
authenticationResult.setDetails(authenticationToken.getDetails());
return authenticationResult;
}
/*
* (non-Javadoc)
*
* @see org.springframework.security.authentication.AuthenticationProvider#
* supports(java.lang.Class)
*/
@Override
public boolean supports(Class<?> authentication) {
return OpenIdAuthenticationToken.class.isAssignableFrom(authentication);
}
public SocialUserDetailsService getUserDetailsService() {
return userDetailsService;
}
public void setUserDetailsService(SocialUserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
public UsersConnectionRepository getUsersConnectionRepository() {
return usersConnectionRepository;
}
public void setUsersConnectionRepository(UsersConnectionRepository usersConnectionRepository) {
this.usersConnectionRepository = usersConnectionRepository;
}
}
4.配置OpenIdAuthenticationSecurityConfig
所有的过滤器还有provider都写好了,接下来就是要把他们配置起来,让他们生效
@Component
public class OpenIdAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
@Autowired
private AuthenticationSuccessHandler imoocAuthenticationSuccessHandler;
@Autowired
private AuthenticationFailureHandler imoocAuthenticationFailureHandler;
@Autowired
private SocialUserDetailsService userDetailsService;
@Autowired
private UsersConnectionRepository usersConnectionRepository;
@Override
public void configure(HttpSecurity http) throws Exception {
OpenIdAuthenticationFilter OpenIdAuthenticationFilter = new OpenIdAuthenticationFilter();
OpenIdAuthenticationFilter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
OpenIdAuthenticationFilter.setAuthenticationSuccessHandler(imoocAuthenticationSuccessHandler);
OpenIdAuthenticationFilter.setAuthenticationFailureHandler(imoocAuthenticationFailureHandler);
OpenIdAuthenticationProvider OpenIdAuthenticationProvider = new OpenIdAuthenticationProvider();
OpenIdAuthenticationProvider.setUserDetailsService(userDetailsService);
OpenIdAuthenticationProvider.setUsersConnectionRepository(usersConnectionRepository);
http.authenticationProvider(OpenIdAuthenticationProvider)
.addFilterAfter(OpenIdAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
5.配置资源服务器
其实就是把第四步中的配置放到资源服务器中去
@Configuration
@EnableResourceServer
public class ImoocResourceServerConfig extends ResourceServerConfigurerAdapter {
@Autowired
protected AuthenticationSuccessHandler imoocAuthenticationSuccessHandler;
@Autowired
protected AuthenticationFailureHandler imoocAuthenticationFailureHandler;
@Autowired
private SmsCodeAuthenticationSecurityConfig smsCodeAuthenticationSecurityConfig;
@Autowired
private OpenIdAuthenticationSecurityConfig openIdAuthenticationSecurityConfig;
@Autowired
private ValidateCodeSecurityConfig validateCodeSecurityConfig;
@Autowired
private SpringSocialConfigurer imoocSocialSecurityConfig;
@Autowired
private SecurityProperties securityProperties;
@Override
public void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginPage(SecurityConstants.DEFAULT_UNAUTHENTICATION_URL)
.loginProcessingUrl(SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_FORM)
.successHandler(imoocAuthenticationSuccessHandler)
.failureHandler(imoocAuthenticationFailureHandler);
http.apply(validateCodeSecurityConfig)
.and()
.apply(smsCodeAuthenticationSecurityConfig)
.and()
.apply(imoocSocialSecurityConfig)
.and()
.apply(openIdAuthenticationSecurityConfig)
.and()
.authorizeRequests()
.antMatchers(
SecurityConstants.DEFAULT_UNAUTHENTICATION_URL,
SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_MOBILE,
SecurityConstants.DEFAULT_LOGIN_PROCESSING_URL_OPENID,
securityProperties.getBrowser().getLoginPage(),
SecurityConstants.DEFAULT_VALIDATE_CODE_URL_PREFIX+"/*",
securityProperties.getBrowser().getSignUpUrl(),
securityProperties.getBrowser().getSession().getSessionInvalidUrl(),
securityProperties.getBrowser().getSignOutUrl(),
"/user/regist")
.permitAll()
.anyRequest()
.authenticated()
.and()
.csrf().disable();
}
}