Oauth2 基于redis的可集群认证服务器demo
功能点
- 支持授权码、账号密码、短信验证码模式获取token
- 授权码、短信验证码基于redis存储
- 刷新token
- 对springSecurity内部的认证机制进行横向优雅扩展
添加依赖
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.5.4.RELEASE</version>
<relativePath/>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session</artifactId>
</dependency>
</dependencies>
在创建认证服务前,我们先定义一个MyUser对象
public class MyUser implements Serializable {
private static final long serialVersionUID = 3497935890426858541L;
private String userName;
private String password;
private boolean accountNonExpired = true;
private boolean accountNonLocked= true;
private boolean credentialsNonExpired= true;
private boolean enabled= true;
// get set 略
}
定义UserDetailService实现
public class MyUserDetailService implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
MyUser user = new MyUser();
user.setUserName(username);
user.setPassword(this.passwordEncoder.encode("123456"));
return new User(username, user.getPassword(), user.isEnabled(),
user.isAccountNonExpired(), user.isCredentialsNonExpired(),
user.isAccountNonLocked(), AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
}
}
这里的逻辑是用什么账号登录都可以,但是密码必须为123456,并且拥有”admin”权限
定义消息渲染类,类似于在Filter做到MVC返回一个实体到前端解析成josn的功能
public class EntityResponseRenderer {
private final Log logger = LogFactory.getLog(EntityResponseRenderer.class);
private List<HttpMessageConverter<?>> messageConverters = geDefaultMessageConverters();
public void setMessageConverters(List<HttpMessageConverter<?>> messageConverters) {
this.messageConverters = messageConverters;
}
public void writeHttpEntityResponse(Object responseObj,
HttpStatus httpStatus,
ServletRequest request, ServletResponse response) {
try {
HttpHeaders headers = new HttpHeaders();
ResponseEntity responseEntity=new ResponseEntity(responseObj,headers, httpStatus);
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
ServletWebRequest webRequest=new ServletWebRequest(req, res);
if (responseEntity == null) {
return;
}
HttpInputMessage inputMessage = createHttpInputMessage(webRequest);
HttpOutputMessage outputMessage = createHttpOutputMessage(webRequest);
if (responseEntity instanceof ResponseEntity && outputMessage instanceof ServerHttpResponse) {
((ServerHttpResponse) outputMessage).setStatusCode(((ResponseEntity<?>) responseEntity).getStatusCode());
}
HttpHeaders entityHeaders = responseEntity.getHeaders();
if (!entityHeaders.isEmpty()) {
outputMessage.getHeaders().putAll(entityHeaders);
}
Object body = responseEntity.getBody();
if (body != null) {
writeWithMessageConverters(body, inputMessage, outputMessage);
}
else {
// flush headers
outputMessage.getBody();
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@SuppressWarnings({ "unchecked", "rawtypes" })
private void writeWithMessageConverters(Object returnValue, HttpInputMessage inputMessage,
HttpOutputMessage outputMessage) throws IOException, HttpMediaTypeNotAcceptableException {
List<MediaType> acceptedMediaTypes = inputMessage.getHeaders().getAccept();
if (acceptedMediaTypes.isEmpty()) {
acceptedMediaTypes = Collections.singletonList(MediaType.ALL);
}
MediaType.sortByQualityValue(acceptedMediaTypes);
Class<?> returnValueType = returnValue.getClass();
List<MediaType> allSupportedMediaTypes = new ArrayList<MediaType>();
for (MediaType acceptedMediaType : acceptedMediaTypes) {
for (HttpMessageConverter messageConverter : messageConverters) {
if (messageConverter.canWrite(returnValueType, acceptedMediaType)) {
messageConverter.write(returnValue, acceptedMediaType, outputMessage);
if (logger.isDebugEnabled()) {
MediaType contentType = outputMessage.getHeaders().getContentType();
if (contentType == null) {
contentType = acceptedMediaType;
}
logger.debug("Written [" + returnValue + "] as \"" + contentType + "\" using ["
+ messageConverter + "]");
}
return;
}
}
}
for (HttpMessageConverter messageConverter : messageConverters) {
allSupportedMediaTypes.addAll(messageConverter.getSupportedMediaTypes());
}
throw new HttpMediaTypeNotAcceptableException(allSupportedMediaTypes);
}
private List<HttpMessageConverter<?>> geDefaultMessageConverters() {
List<HttpMessageConverter<?>> result = new ArrayList<HttpMessageConverter<?>>();
result.addAll(new RestTemplate().getMessageConverters());
result.add(new JaxbOAuth2ExceptionMessageConverter());
return result;
}
private HttpInputMessage createHttpInputMessage(NativeWebRequest webRequest) throws Exception {
HttpServletRequest servletRequest = webRequest.getNativeRequest(HttpServletRequest.class);
return new ServletServerHttpRequest(servletRequest);
}
private HttpOutputMessage createHttpOutputMessage(NativeWebRequest webRequest) throws Exception {
HttpServletResponse servletResponse = (HttpServletResponse) webRequest.getNativeResponse();
return new ServletServerHttpResponse(servletResponse);
}
}
定义基于redis的授权码存储器
/**
* 默认的授权码存储为InMemoryAuthorizationCodeServices, 不适用于微服务场景, 需要
* 自定义一个基于redis的授权码存储器
* @program: spring-security-demo
* @description:
* @author: chenzejie
* @create: 2019-09-04 13:49
**/
public class InRedisAuthorizationCodeServices extends RandomValueAuthorizationCodeServices {
private RedisTemplate<String,OAuth2Authentication> redisTemplate;
private String prefix="authorization:code:";
public InRedisAuthorizationCodeServices(RedisTemplate<String,OAuth2Authentication> redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
protected void store(String code, OAuth2Authentication authentication) {
redisTemplate.opsForValue().set(key(code),authentication,120, TimeUnit.SECONDS);
}
public String key(String code){
return prefix+code;
}
@Override
protected OAuth2Authentication remove(String code) {
OAuth2Authentication oAuth2Authentication=redisTemplate.opsForValue().get(key(code));
if(oAuth2Authentication!=null){
redisTemplate.delete(key(code));
}
return oAuth2Authentication;
}
}
定义oauth2 登出过滤器
/**
* @program: spring-security-demo
* @description: oauth2登出过滤器
* @author: chenzejie
* @create: 2019-09-06 17:00
**/
public class Oauth2LogoutFilter extends GenericFilterBean {
private RequestMatcher requiresLogoutRequestMatcher;
private String logoutSucessResponse;
private TokenStore tokenStore;
private EntityResponseRenderer entityResponseRenderer=new EntityResponseRenderer();
/**
* 默认构造参数需要接收一个请求匹配器,用于处理指定的登出请求
* @param requiresLogoutRequestMatcher
*/
public Oauth2LogoutFilter(RequestMatcher requiresLogoutRequestMatcher) {
this.requiresLogoutRequestMatcher=requiresLogoutRequestMatcher;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
/**
* 如果请求不符合登出请求则忽略
*/
if(!requiresLogout((HttpServletRequest)request)){
chain.doFilter(request, response);
return;
}
/**
* 因为当前过滤器是绑定在OAuth2AuthenticationProcessingFilter后面的, OAuth2AuthenticationProcessingFilter
* 是负责校验token的合法性, 当到达这里时证明身份已经通过校验,则可以拿到上下文的身份信息
*/
Authentication authentication=SecurityContextHolder.getContext().getAuthentication();
if(authentication!=null){
/**
* OAuth2AuthenticationProcessingFilter 通过校验后会把token存放在request的作用域里
* key为OAuth2AuthenticationDetails.ACCESS_TOKEN_VALUE
*/
String access_token=(String)request.getAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_VALUE);
//设置当前上下文的身份信息为null
SecurityContextHolder.getContext().setAuthentication(null);
//从token存储器中获取OAuth2AccessToken
OAuth2AccessToken oAuth2AccessToken=tokenStore.readAccessToken(access_token);
/**
* 使用token存储器删除accessToken 和 refreshToken
*/
if(oAuth2AccessToken!=null){
tokenStore.removeAccessToken(oAuth2AccessToken);
}
if(oAuth2AccessToken.getRefreshToken()!=null){
tokenStore.removeRefreshToken(oAuth2AccessToken.getRefreshToken());
}
/**
* 成功退出后的处理, 里面暂时做了输出一窜成功登出的字符串, 你可以在里面
* 扩展自己的用户退出逻辑
*/
onSuccessLogout(request,response);
return;
}
}
private void onSuccessLogout(ServletRequest request, ServletResponse response) throws IOException {
entityResponseRenderer.writeHttpEntityResponse(
new MessageEntity(logoutSucessResponse, HttpStatus.OK.value()),
HttpStatus.OK,
request,
response
);
}
protected boolean requiresLogout(HttpServletRequest request) {
return requiresLogoutRequestMatcher.matches(request);
}
public void setLogoutSucessResponse(String logoutSucessResponse) {
this.logoutSucessResponse = logoutSucessResponse;
}
public void setTokenStore(TokenStore tokenStore) {
this.tokenStore = tokenStore;
}
}
/**
* @program: spring-security-demo
* @description: 登出过滤器的配置项,委派给HttpSecurity调用
* @author: chenzejie
* @create: 2019-09-06 17:23
**/
public class Oauth2LogoutConfigurer extends
SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
/**
* 处理退出请求的地址
*/
private String defaultLogoutUrl="/oauth2/logout";
/**
* 退出成功后的字符串信息
*/
private String logoutSuccessResponse="{'code':1,'message':'成功退出'}";
/**
* token存储器
*/
private TokenStore tokenStore;
@Override
public void configure(HttpSecurity http) throws Exception {
Oauth2LogoutFilter filter=new Oauth2LogoutFilter(new AntPathRequestMatcher(defaultLogoutUrl));
filter.setTokenStore(tokenStore);
filter.setLogoutSucessResponse(logoutSuccessResponse);
//把Oauth2LogoutFilter作用在OAuth2AuthenticationProcessingFilter后
http.addFilterAfter(filter, OAuth2AuthenticationProcessingFilter.class);
}
public Oauth2LogoutConfigurer defaultLogoutUrl(String defaultLogoutUrl) {
this.defaultLogoutUrl = defaultLogoutUrl;
return this;
}
public Oauth2LogoutConfigurer logoutSuccessResponse(String logoutSuccessResponse) {
this.logoutSuccessResponse = logoutSuccessResponse;
return this;
}
public Oauth2LogoutConfigurer tokenStore(TokenStore tokenStore) {
this.tokenStore = tokenStore;
return this;
}
}
手机验证码认证相关代码
Redis操作手机验证码相关代码
/**
* Redis操作手机验证码服务
*/
@Service
public class RedisCodeService {
private final static String SMS_CODE_PREFIX = "SMS_CODE:";
private final static Integer TIME_OUT = 300;
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 保存验证码到 redis
*
* @param smsCode 短信验证码
*/
public void save(String smsCode, String mobile) {
redisTemplate.opsForValue().set(key( mobile), smsCode , TIME_OUT, TimeUnit.SECONDS);
}
/**
* 获取验证码
*
* @return 验证码
*/
public String get( String mobile) {
return redisTemplate.opsForValue().get(key( mobile));
}
/**
* 移除验证码
*
*/
public void remove( String mobile) {
redisTemplate.delete(key( mobile));
}
private String key( String mobile) {
return SMS_CODE_PREFIX + ":" + mobile;
}
}
定义基于短信认证的异常
/**
* @program: spring-security-demo
* @description: 定义基于短信认证的异常
* @author: chenzejie
* @create: 2019-09-02 16:44
**/
public class SmsAuthenicationException extends AuthenticationException {
public SmsAuthenicationException(String msg, Throwable t) {
super(msg, t);
}
public SmsAuthenicationException(String msg) {
super(msg);
}
}
短信验证码授权过滤器
/**
* @program: spring-security-demo
* @description: 短信验证码授权过滤器,用于给用户发送短信验证码
* @author: chenzejie
* @create: 2019-09-02 16:37
**/
public class SmsAuthorizeFilter extends GenericFilterBean {
private RedisCodeService redisCodeService;
private RequestMatcher requiresAuthenticationRequestMatcher;
private EntityResponseRenderer entityResponseRenderer=new EntityResponseRenderer();
public SmsAuthorizeFilter() {
processesUrl("/oauth/sms/authorize");
}
public RedisCodeService getRedisCodeService() {
return redisCodeService;
}
public void setRedisCodeService(RedisCodeService redisCodeService) {
this.redisCodeService = redisCodeService;
}
@Override
public void afterPropertiesSet() throws ServletException {
Assert.notNull(redisCodeService, "RedisCodeService must be specified");
}
public void processesUrl(String filterProcessesUrl) {
this.requiresAuthenticationRequestMatcher=new AntPathRequestMatcher(filterProcessesUrl);
}
protected boolean requiresAuthentication(HttpServletRequest request) {
return requiresAuthenticationRequestMatcher.matches(request);
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
if (!requiresAuthentication((HttpServletRequest)request)) {
chain.doFilter(request, response);
return;
}
String mobile=request.getParameter("mobile");
if(StringUtils.isEmpty(mobile)){
entityResponseRenderer.writeHttpEntityResponse(
new MessageEntity("手机号不能为空",HttpStatus.UNAUTHORIZED.value()),
HttpStatus.UNAUTHORIZED,
request,
response
);
return;
}
String code=new Random().nextInt(999999)+"";
redisCodeService.save(code,mobile);
entityResponseRenderer.writeHttpEntityResponse(
new MessageEntity("短信发送成功:"+code,HttpStatus.OK.value()),
HttpStatus.OK,
request,
response
);
}
}
基于短信认证的token
/**
* @program: spring-security-demo
* @description: 基于短信认证的token
* @author: chenzejie
* @create: 2019-09-02 16:16
**/
public class SmsAuthenicationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private final Object principal;
private final Object credentials;
public SmsAuthenicationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials=credentials;
setAuthenticated(false);
}
public SmsAuthenicationToken(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true); // must use super, as we override
}
@Override
public Object getCredentials() {
return credentials;
}
@Override
public Object getPrincipal() {
return this.principal;
}
@Override
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();
}
}
基于短信的身份认证器
/**
* @program: spring-security-demo
* @description: 基于短信的身份认证器
* @author: chenzejie
* @create: 2019-09-02 16:14
**/
public class SmsAuthenicationProvider implements AuthenticationProvider {
private UserDetailsService userDetailService;
private RedisCodeService redisCodeService;
public SmsAuthenicationProvider(UserDetailsService userDetailService) {
this.userDetailService = userDetailService;
}
public RedisCodeService getRedisCodeService() {
return redisCodeService;
}
public void setRedisCodeService(RedisCodeService redisCodeService) {
this.redisCodeService = redisCodeService;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
SmsAuthenicationToken smsAuthenicationToken=(SmsAuthenicationToken)authentication;
String mobile=smsAuthenicationToken.getPrincipal().toString();
String redisCode=redisCodeService.get(mobile);
String code=smsAuthenicationToken.getCredentials().toString();
if(StringUtils.isEmpty(redisCode)) {
throw new SmsAuthenicationException("请重新发送验证码");
}
if(!redisCode.equals(code)) {
throw new SmsAuthenicationException("验证码错误");
}
UserDetails userDetail= userDetailService.loadUserByUsername(mobile);
SmsAuthenicationToken result=new SmsAuthenicationToken(userDetail,null, userDetail.getAuthorities());
//最后要删掉验证码
redisCodeService.remove(mobile);
return result;
}
@Override
public boolean supports(Class<?> authentication) {
/**
* 当 authentication的类型为SmsAuthenicationToken 会使用本类的认证器进行身份认证
*/
return (SmsAuthenicationToken.class
.isAssignableFrom(authentication));
}
}
为springSecurity扩展基于短信的身份认证器的配置项
/**
* @program: spring-security-demo
* @description:
* 为springSecurity扩展基于短信的身份认证器 SmsAuthenicationProvider,
* 的配置项,委派给HttpSecurity调用
* @author: chenzejie
* @create: 2019-09-24 18:11
**/
public class SmsAuthenticationConfigurerAdapter extends GlobalAuthenticationConfigurerAdapter {
private final ApplicationContext context;
public SmsAuthenticationConfigurerAdapter(ApplicationContext context) {
this.context = context;
}
/**
* 这里需要添加springSecurity默认的账号密码验证器 DaoAuthenticationProvider,
* DaoAuthenticationProvider默认在 InitializeUserDetailsBeanManagerConfigurer 进行配置, 如果
* 对springSecurity的身份验证器进行扩展则会失效。所以在这里我们需要配置一个
* 默认的账号密码验证器 DaoAuthenticationProvider
*
* @param auth
*/
private void buildDefaultUserDetailsManager(AuthenticationManagerBuilder auth){
UserDetailsService userDetailsService = getBeanOrNull(
UserDetailsService.class);
if (userDetailsService == null) {
return;
}
PasswordEncoder passwordEncoder = getBeanOrNull(PasswordEncoder.class);
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
if (passwordEncoder != null) {
provider.setPasswordEncoder(passwordEncoder);
}
auth.authenticationProvider(provider);
}
@Override
public void init(AuthenticationManagerBuilder auth) throws Exception {
buildDefaultUserDetailsManager(auth);
UserDetailsService userDetailsService = getBeanOrNull(
UserDetailsService.class);
if (userDetailsService == null) {
return;
}
RedisCodeService redisCodeService=getBeanOrNull(
RedisCodeService.class);
SmsAuthenicationProvider smsAuthenicationProvider=new SmsAuthenicationProvider(userDetailsService);
smsAuthenicationProvider.setRedisCodeService(redisCodeService);
auth.authenticationProvider(smsAuthenicationProvider);
}
/**
* @return
*/
private <T> T getBeanOrNull(Class<T> type) {
String[] userDetailsBeanNames = this.context
.getBeanNamesForType(type);
if (userDetailsBeanNames.length != 1) {
return null;
}
return this.context
.getBean(userDetailsBeanNames[0], type);
}
}
扩展spring security的token获取方式
/**
* @program: spring-security-demo
* @description: 扩展spring security的token获取方式,原生的的token获取方式只支持 password\authorization_code 这些
* 如果需要扩展短信验证码获取token需要继承AbstractTokenGranter 进行扩展
* @author: chenzejie
* @create: 2019-09-24 13:43
**/
public class ResourceOwnerSmsTokenGranter extends AbstractTokenGranter {
private static final String GRANT_TYPE = "sms";
private final AuthenticationManager authenticationManager;
public ResourceOwnerSmsTokenGranter(AuthenticationManager authenticationManager,
AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory) {
this(authenticationManager, tokenServices, clientDetailsService, requestFactory, GRANT_TYPE);
}
protected ResourceOwnerSmsTokenGranter(AuthenticationManager authenticationManager, AuthorizationServerTokenServices tokenServices,
ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory, String grantType) {
super(tokenServices, clientDetailsService, requestFactory, grantType);
this.authenticationManager = authenticationManager;
}
@Override
protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {
Map<String, String> parameters = new LinkedHashMap<String, String>(tokenRequest.getRequestParameters());
String mobile = parameters.get("username");
String validCode = parameters.get("code");
// Protect from downstream leaks of password
parameters.remove("code");
/**
* 构造要进行认证的AbstractAuthenticationToken , 这里我们创建SmsAuthenicationToken,这样就会委派给
* SmsAuthenicationProvider进行认证
*/
Authentication userAuth = new SmsAuthenicationToken(mobile,validCode);
((AbstractAuthenticationToken) userAuth).setDetails(parameters);
try {
userAuth = authenticationManager.authenticate(userAuth);
}
catch (AccountStatusException ase) {
//covers expired, locked, disabled cases (mentioned in section 5.2, draft 31)
throw new InvalidGrantException(ase.getMessage());
}
catch (BadCredentialsException e) {
// If the username/password are wrong the spec says we should send 400/invalid grant
throw new InvalidGrantException(e.getMessage());
}
if (userAuth == null || !userAuth.isAuthenticated()) {
throw new InvalidGrantException("Could not authenticate user: " + mobile);
}
OAuth2Request storedOAuth2Request = getRequestFactory().createOAuth2Request(client, tokenRequest);
return new OAuth2Authentication(storedOAuth2Request, userAuth);
}
}
启动配置相关代码
application.properties
security.basic.enabled=false
spring.redis.host=10.0.0.77
spring.redis.port=6379
#Session集群处理 把session的存储方式修改为redis,实现session集群
spring.session.store-type=redis
Spring配置类入口
/**
* 配置入口, 初始化相关的bean,并加载 AuthorizationServerConfig ResourceServerConfig
*/
@Configuration
@Import({AuthorizationServerConfig.class,ResourceServerConfig.class})
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private UserDetailsService userDetailService;
@Autowired
private RedisCodeService redisCodeService;
/**
* 创建MyUserDetailService实例 并在IOC容器注册
* @return
*/
@Bean(name = BeanIds.USER_DETAILS_SERVICE)
public UserDetailsService userDetailService(){
this.userDetailService=new MyUserDetailService();
return this.userDetailService;
}
/**
* 为springSecurity扩展基于短信的身份认证器 SmsAuthenicationProvider的配置项
* springSecurity在启动后会调用在IOC容器所有的GlobalAuthenticationConfigurerAdapter实现类的init方法,
* 为spring security的身份认证进行扩展
*/
@Bean
public GlobalAuthenticationConfigurerAdapter smsAuthenticationConfigurerAdapter(
ApplicationContext applicationContext,
RedisCodeService redisCodeService
){
return new SmsAuthenticationConfigurerAdapter(applicationContext);
}
/**
* 创建RedisToken存储,并在IOC容器注册,默认spring oauth2使用的是InMemoryTokenStore, 不适用于微服务场景
* @param redisConnectionFactory 依赖于RedisConnectionFactory 需要spring注入
* @return
*/
@Bean
@Autowired
public TokenStore tokenStore(RedisConnectionFactory redisConnectionFactory){
RedisTokenStore redisTokenStore=new RedisTokenStore(redisConnectionFactory);
return redisTokenStore;
}
/**
* 密码加密器,并在IOC容器注册
* @return
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 创建基于redis的授权码存储服务,并在IOC容器注册
* @return
*/
@Autowired
@Bean
public InRedisAuthorizationCodeServices inRedisAuthorizationCodeServices(RedisTemplate redisTemplate){
redisTemplate.setKeySerializer(new StringRedisSerializer());
return new InRedisAuthorizationCodeServices(redisTemplate);
}
@Override
protected void configure(HttpSecurity http) throws Exception {
/**
* 构造短信发送过滤器,并把该过滤器绑定到ExceptionTranslationFilter前面
*/
SmsAuthorizeFilter smsAuthorizeFilter=new SmsAuthorizeFilter();
smsAuthorizeFilter.setRedisCodeService(redisCodeService);
String authorizeSmsPath="/oauth/sms/authorize";
/**
* 当前的权限拦截器组只拦截 /login /oauth/authorize /oauth/sms/authorize 3个请求
* /oauth/authorize: spring-security的获取授权码的接口地址,并跳转到第三方地址的接口地址,
* 但是默认的spring-security-oauth2 的AuthorizationServerSecurityConfiguration
* 构造的拦截器组没有对/oauth/authorize 进行拦截,导致若没进行身份认证, 访问该接口则会抛异常,所以在本过滤器组添加拦截
*
* /oauth/sms/authorize: 获取手机验证码, 不需要进行身份认证
*
* /login: 用户获取授权码时候,会进行身份校验, 若未登陆会跳转到/login 登录页 也需要显示声明该拦截地址
*
*/
http.requestMatchers()
.antMatchers("/login","/oauth/authorize",authorizeSmsPath).and()
.formLogin().and()
.authorizeRequests()
.antMatchers(authorizeSmsPath).permitAll()
.anyRequest().authenticated()
.and()
.csrf().disable()
.addFilterAfter(smsAuthorizeFilter, ExceptionTranslationFilter.class)
;
}
}
认证服务配置
/**
* @program: spring-security-demo
* @description: 认证服务配置
* @author: chenzejie
* @create: 2019-09-02 09:49
**/
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private InRedisAuthorizationCodeServices inRedisAuthorizationCodeServices;
@Autowired
private RedisConnectionFactory redisConnectionFactory;
@Autowired
private TokenStore tokenStore;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private UserDetailsService userDetailsService;
/**
* 自定义token授权器
* spring-security默认的token生成器在AuthorizationServerEndpointsConfigurer.getDefaultTokenGranters()
* 生成, 如果开发者想扩展自己的token生成器, 需要把原有的逻辑代码拷贝过来, 并追加自己的token生成器,本方法就是
* 沿袭getDefaultTokenGranters()的代码逻辑, 并追加了ResourceOwnerSmsTokenGranter 短信token生成器
*
* @return
*/
public TokenGranter customTokenGranter(ClientDetailsService clientDetails
,AuthorizationServerTokenServices tokenServices, AuthorizationCodeServices authorizationCodeServices,
OAuth2RequestFactory requestFactory
){
List<TokenGranter> defaultTokenGranters = new ArrayList<TokenGranter>();
defaultTokenGranters.add(new AuthorizationCodeTokenGranter(tokenServices, authorizationCodeServices, clientDetails,
requestFactory));
defaultTokenGranters.add(new RefreshTokenGranter(tokenServices, clientDetails, requestFactory));
ImplicitTokenGranter implicit = new ImplicitTokenGranter(tokenServices, clientDetails, requestFactory);
defaultTokenGranters.add(implicit);
defaultTokenGranters.add(new ClientCredentialsTokenGranter(tokenServices, clientDetails, requestFactory));
if (authenticationManager != null) {
defaultTokenGranters.add(new ResourceOwnerPasswordTokenGranter(authenticationManager, tokenServices,
clientDetails, requestFactory));
defaultTokenGranters.add(new ResourceOwnerSmsTokenGranter(authenticationManager, tokenServices,
clientDetails, requestFactory));
}
TokenGranter tokenGranter=new CompositeTokenGranter(defaultTokenGranters);
return tokenGranter;
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
/**
* 对认证的token接口进行配置
* tokenStore(tokenStore): 配置基于redis的token存储器
* authorizationCodeServices(inRedisAuthorizationCodeServices):配置基于redis的授权码存储器
* authenticationManager(authenticationManager):配置身份认证服务,如果不配置则不支持基于密码的授权模式
* .tokenGranter(): 配置自定义token生成器
* .userDetailsService(userDetailsService): 主要作用于刷新token操作重新读取用户信息
*/
endpoints
.tokenStore(tokenStore)
.authorizationCodeServices(inRedisAuthorizationCodeServices)
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService)
.tokenGranter(
customTokenGranter(endpoints.getClientDetailsService(),endpoints.getTokenServices(),
endpoints.getAuthorizationCodeServices(),endpoints.getOAuth2RequestFactory()
)
)
;
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
//如果AppId的秘钥是进行加密的,则需要添加为AuthorizationServerSecurityConfigurer配置密码加密器
security.passwordEncoder(passwordEncoder);
}
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
/**
* 配置appClient详情, 下面演示的是基于内存存储的, 不可在程序运行期间进行修改,不推荐使用
* 建议使用clients.jdbc(), 把appClient存储在数据库里,你也可以扩展基于redis mongodb的appClient
* 读取器, 需要实现ClientDetailsService
*
*/
clients.inMemory()
//appId
.withClient("test")
//秘钥
.secret(passwordEncoder.encode("test1234"))
//token失效时间(秒)
.accessTokenValiditySeconds(3600)
//刷新token失效时间(秒)
.refreshTokenValiditySeconds(864000)
//作用域
.scopes("all")
//app注册跳转地址
.redirectUris("http://www.baidu.com")
//支持的token生成模式, 这里把常见的密码模式、授权码模式、刷新token模式、短信认证模式都加上了
.authorizedGrantTypes("password","authorization_code","refresh_token","sms")
;
}
}
资源服务配置
/**
* @program: spring-security-demo
* @description: 资源服务配置, 主要作用是做登出的处理,因为登出需要依赖于token校验,
* token校验的过滤器 OAuth2AuthenticationProcessingFilter 在ResourceServerConfiguration进行配置
* 所以需要加载资源服务的配置项
* @author: chenzejie
* @create: 2019-09-02 10:44
**/
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Autowired
private TokenStore tokenStore;
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
}
@Override
public void configure(HttpSecurity http) throws Exception {
super.configure(http);
//把OAuth2登出配置类委派给HttpSecurity调用
http.apply(new Oauth2LogoutConfigurer()).tokenStore(tokenStore);
}
}
测试基于密码模式获取token
使用postman发送POST请求localhost:8080/oauth/token:
grant_type填password,表示密码模式,然后填写用户名和密码,除了这几个参数外,
我们还需要在请求头中填写:
key为Authorization,value为Basic加上client_id:client_secret经过base64加密后的值: Basic dGVzdDp0ZXN0MTIzNA==
你可以在下面提供的在线加密链接进行加密测试
(http://tool.chinaz.com/Tools/Base64.aspx):
参数填写无误后,点击发送便可以获取到令牌Token:
{
"access_token": "b52ab19a-dc6c-453a-900f-9563c7f5ce3b",
"token_type": "bearer",
"refresh_token": "92423cbc-75e0-415d-be94-af1f7ee032de",
"expires_in": 3599,
"scope": "all"
}
测试基于短信模式获取token
1、使用postman发送获取短信请求:
localhost:8080/oauth/sms/authorize?mobile=13242404681
2、使用postman发送POST请求:localhost:8080/oauth/token
grant_type填sms,表示短信验证码模式,code填收到的短信,头部也需要填写Authorization信息,内容和密码模式介绍的一致,这里就不截图了。点击发送,也可以获得令牌:
{
"access_token": "b52ab19a-dc6c-453a-900f-9563c7f5ce3b",
"token_type": "bearer",
"refresh_token": "92423cbc-75e0-415d-be94-af1f7ee032de",
"expires_in": 3599,
"scope": "all"
}
测试基于授权码模式获取token
接下来开始往认证服务器请求授权码。打开浏览器,访问
http://localhost:8080/oauth/authorize?response_type=code&client_id=test&redirect_uri=http://www.baidu.com&scope=all&state=hello
URL中的几个参数,response_type必须为code,表示授权码模式,client_id就是刚刚在配置文件中手动指定的test,redirect_uri这里为客户端配置的跳转地址,主要是用来重定向获取授权码的,scope指定为all,表示所有权限。
访问这个链接后,页面如下所示:
如果要自定义登陆页面, 在SecurityConfig里为HttpSecurity配置formLogin的loginPage方法配置
需要登录认证,根据我们前面定义的UserDetailService逻辑,这里用户名随便输,密码为123456即可。输入后,页面跳转如下所示:
选择同意Approve,然后点击Authorize按钮后,页面跳转到了我们指定的redirect_uri,并且带上了授权码信息:
到这里我们就可以用这个授权码从认证服务器获取令牌Token了。
使用postman发送如下请求POST请求localhost:8080/oauth/token:
grant_type固定填authorization_code,code为上一步获取到的授权码,client_id和redirect_uri、scope必须和我们上面定义的一致。 头部也需要填写Authorization信息,内容和密码模式介绍的一致,这里就不截图了。点击发送,也可以获得令牌:
{
"access_token": "60d373b3-3ad1-46d7-99fd-1501d5cac572",
"token_type": "bearer",
"refresh_token": "c9688f78-836d-4b32-9a64-a0890c87c81e",
"expires_in": 3599,
"scope": "all"
}
自定义授权页
在这里我们看到登陆页面和授权页面很简陋, 不符合线上的需求,自定义页面的配置很简单,这里就不概述了,在这里介绍下如何自定义授权页面,在上面的代码基础下, 我们增加如下配置:
添加模板依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
配置模板文件
在application.properties追下如下内容
spring.thymeleaf.prefix=classpath:/views/
spring.thymeleaf.suffix=.html
spring.thymeleaf.cache=false
在resources/views 新建base-grant.html 授权页面文件
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8"/>
<title>授权</title>
</head>
<style>
html{
padding: 0px;
margin: 0px;
}
.title {
background-color: #E9686B;
height: 50px;
padding-left: 20%;
padding-right: 20%;
color: white;
line-height: 50px;
font-size: 18px;
}
.title-left{
float: right;
}
.title-right{
float: left;
}
.title-left a{
color: white;
}
.container{
clear: both;
text-align: center;
}
.btn {
width: 350px;
height: 35px;
line-height: 35px;
cursor: pointer;
margin-top: 20px;
border-radius: 3px;
background-color: #E9686B;
color: white;
border: none;
font-size: 15px;
}
</style>
<body style="margin: 0px">
<div class="title">
<div class="title-right">OAUTH-BOOT 授权</div>
<div class="title-left">
<a href="#help">帮助</a>
</div>
</div>
<div class="container">
<h3 th:text="${clientId}+' 请求授权,该应用将获取你的以下信息'"></h3>
<p>昵称,头像和性别</p>
授权后表明你已同意 <a href="#boot" style="color: #E9686B">OAUTH-BOOT 服务协议</a>
<form method="post" action="/oauth/authorize">
<input type="hidden" name="user_oauth_approval" value="true"/>
<div th:each="item:${scopes}">
<input type="radio" th:name="'scope.'+${item}" value="true" hidden="hidden" checked="checked"/>
</div>
<button class="btn" type="submit"> 同意/授权</button>
</form>
</div>
</body>
</html>
定义界面Controller
spring-security内部跳转授权页面的地址为/oauth/confirm_access,所以你的接口地址定义为/oauth/confirm_access即可覆盖spring-security默认的授权页面
@Controller
@SessionAttributes("authorizationRequest")
public class BootGrantController {
@RequestMapping("/oauth/confirm_access")
public ModelAndView getAccessConfirmation(Map<String, Object> model, HttpServletRequest request) throws Exception {
AuthorizationRequest authorizationRequest = (AuthorizationRequest) model.get("authorizationRequest");
ModelAndView view = new ModelAndView();
view.setViewName("base-grant");
view.addObject("clientId", authorizationRequest.getClientId());
view.addObject("scopes",authorizationRequest.getScope());
return view;
}
}
效果图
测试刷新token
我们回顾前面获取到的token内容:
{
"access_token": "54438695-8c98-4553-ab71-764e8b8ccdbe",
"token_type": "bearer",
"refresh_token": "20ca6925-76d8-405a-b67e-00c9534a28c8",
"expires_in": 3521,
"scope": "all"
}
其中access_token是我们访问资源服务器所需要携带的令牌,但是这个令牌会有过期时间,过期时间为expires_in字段信息,默认为1小时,当过期后我们需要重新获取新的token,除了前面说的几种获取token方式外,还有一种是根据刷新令牌获取新的token,实例如下:
使用postman发送如下请求POST请求localhost:8080/oauth/token:
image grant_type固定填refresh_token,refresh_token 为获取到的刷新token。 头部也需要填写Authorization信息,内容和密码模式介绍的一致,这里就不截图了。点击发送,也可以获得令牌:
{
"access_token": "188d997c-8c0e-4a70-adf7-3d69c06d1645",
"token_type": "bearer",
"refresh_token": "20ca6925-76d8-405a-b67e-00c9534a28c8",
"expires_in": 3599,
"scope": "all"
}