Spring Security 学习之使用篇

1,095 阅读10分钟

服务端开发中常会接触到安全框架,而在Java生态中最常见的莫过于 Apache_ShiroSpring Security。本系列文章主要探讨Spring Security框架的常见使用范例,并且深入了解其背后的实现原理。

简介

Spring Security 框架提供身份认证(authentication)、授权(authorization)以及防范常见攻击等三大功能。在身份认证上,Spring Security只是提供框架,具体的认证模型大多由第三方提供。本文主要介绍企业后台管理系统中最常见的基于表单的身份认证以及LDAP身份认证。

环境

通过Spring Boot 可以快速集成Spring Security

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-security</artifactId>
</dependency>

本文所用Spring Boot版本为2.3.2.RELEASE

配置HttpSecurity

后台管理系统中常常需要自定义登录页、自定义异常跳转页面、跨域访问控制、跨站请求控制、Remember-Me(”记住我“)等等。这些都可以通过HttpSecurity来配置实现,常见的配置样例如下所示:

public class GeneralSecurityConfiguration extends WebSecurityConfigurerAdapter {

    private static final String[] BY_PASS_URLS = {"/styles/**", "/views/**", "/img/**", "/i18n/**", "/health"};

    @Override
    protected void configure(HttpSecurity http) throws Exception {
		// 关闭跨站伪造攻击检查
        http.csrf().disable();

        // 设置X-Frame-Options: SAMEORIGIN
        http.headers().frameOptions().sameOrigin();
        
        // 部分访问路径进行权限控制
        http.authorizeRequests()
                .antMatchers(BY_PASS_URLS).permitAll()
                .antMatchers("/**").authenticated();

        // 自定义登录页面
        http.formLogin()
                .loginPage("/login")
                .defaultSuccessUrl("/index", true)
                .permitAll()
                .failureUrl("/login").and();

        // 自定义登出页面
        http.logout()
                .logoutUrl("/logout")
                .invalidateHttpSession(true)
                .clearAuthentication(true)
                .logoutSuccessUrl("/login");

        // 自定义异常跳转页面
        http.exceptionHandling()
                .authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))
                .and()
                .exceptionHandling().accessDeniedHandler(new AccessDeniedHandlerImpl());
        
        // 配置remember-me
        rememberMe(http);
    }


    protected void rememberMe(HttpSecurity http) throws Exception {
        http.rememberMe().rememberMeServices(new NullRememberMeServices());
    }
}

上述配置完成了以下几件事情:

以上只是演示样例,具体还需要根据业务进行调整。

除了配置HttpSecurity,其他代码不是本文关注重点,请移步仓库:spring-tool/spring-security-demo

再回到之前的默认配置,启动后会自动生成一个InMemoryUserDetailsManager实例,该实例是UserDetailsService接口的实现(可见UserDetailsServiceAutoConfiguration)。InMemoryUserDetailsManager在内存里会自动生成一个用户名和密码,并在控制台中打印:

Using generated security password: 293c4e55-f111-4441-9db5-5d1a531052d3

通过这个用户名密码便可以登录。

除了使用HttpSecurity进行配置,还可以使用WebSecurity 和 AuthenticationManagerBuilder,三者的区别参考Q&A From StackOverFlow

下边我们便在上述范例基础上进行扩展。

自定义UserDetailsService

先看接口定义:

/**
* Core interface which loads user-specific data.
* It is used throughout the framework as a user DAO and is the strategy used by the DaoAuthenticationProvider.
* The interface requires only one read-only method, which simplifies support for new data-access strategies.
**/
public interface UserDetailsService {
	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

这个接口用来根据用户名加载用户信息,此外这个接口一般在DaoAuthenticationProvider中调用:

protected final UserDetails retrieveUser(String username,
			UsernamePasswordAuthenticationToken authentication)
			throws AuthenticationException {
	prepareTimingAttackProtection();
	try {
		// 调用loadUserByUsername获取用户账号密码信息
		UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
		if (loadedUser == null) {
			throw new InternalAuthenticationServiceException(
					"UserDetailsService returned null, which is an interface contract violation");
		}
		return loadedUser;
	}
	catch (UsernameNotFoundException ex) {
		mitigateAgainstTimingAttack(authentication);
		throw ex;
	}
	catch (InternalAuthenticationServiceException ex) {
		throw ex;
	}
	catch (Exception ex) {
		throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
	}
}

因此我们可以自定义UserDetailsService来提供用户账户密码信息,而不是使用默认生成的bean实例。如下:


@Component
@Profile("form")
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityFormLoginConfiguration extends GeneralSecurityConfiguration {
    @Bean
    public UserDetailsService createCustomServiceDetailService() {
        return new CustomServiceDetailService();
    }

    static class CustomServiceDetailService implements UserDetailsService {
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            return new User(username, "{noop}123", AuthorityUtils.commaSeparatedStringToAuthorityList(ROLE_ADMIN + "," + ROLE_USER));
        }
    }
}

如上,我们通过注册一个CustomServiceDetailService实例,实例方法里返回的默认密码为123。现在通过这个密码我们也可以登录系统。

  • 为何密码指定了前缀"{noop}"?
    • 因为框架默认使用了DelegatingPasswordEncoder来进行密码的加密,通过指定前缀"{noop}"表示密码不作任何加密。用户也可以指定自定义Encoder,那样的话password也要跟随变更。

从数据库加载用户信息

上文的范例的CustomServiceDetailService和默认生成的InMemoryUserDetailsManager 在功能上并无区别,但这里仅仅是提供一个思路。我们可以通过UserDetailsService来实现更多可能,比如从数据库加载用户信息。

这里不打算通过编写dao读写代码,然后演示怎么加载用户信息。而是来看看Spring Security提供给我们JdbcUserDetailsManager类。通过JdbcUserDetailsManager我们可以非常快速地建立一个在数据库保存和加载用户信息的范例:

@Component
@Profile("jdbc")
public class SecurityJdbcConfiguration extends GeneralSecurityConfiguration {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public JdbcUserDetailsManager jdbcUserDetailsManager(DataSource datasource) {
        JdbcUserDetailsManager jdbcUserDetailsManager = new JdbcUserDetailsManager(datasource);
        jdbcUserDetailsManager.setUsersByUsernameQuery("select Username,Password,Enabled from `Users` where Username = ?");
        jdbcUserDetailsManager.setAuthoritiesByUsernameQuery("select Username,Authority from `Authorities` where Username = ?");

        jdbcUserDetailsManager.setUserExistsSql("select Username from `Users` where Username = ?");
        jdbcUserDetailsManager
                .setCreateUserSql("insert into `Users` (Username, Password, Enabled) values (?,?,?)");
        jdbcUserDetailsManager
                .setUpdateUserSql("update `Users` set Password = ?, Enabled = ? where id = (select u.id from (select id from `Users` where Username = ?) as u)");
        jdbcUserDetailsManager.setDeleteUserSql("delete from `Users` where id = (select u.id from (select id from `Users` where Username = ?) as u)");
        jdbcUserDetailsManager
                .setCreateAuthoritySql("insert into `Authorities` (Username, Authority) values (?,?)");
        jdbcUserDetailsManager
                .setDeleteUserAuthoritiesSql("delete from `Authorities` where id in (select a.id from (select id from `Authorities` where Username = ?) as a)");
        jdbcUserDetailsManager
                .setChangePasswordSql("update `Users` set Password = ? where id = (select u.id from (select id from `Users` where Username = ?) as u)");

        return jdbcUserDetailsManager;
    }
}

JdbcUserDetailsManager本质上就是UserDetailsService的一个实现。如下:

JdbcUserDetailsManager.png

所以这个样例的套路其实和上一个范例一样。只不过用户数据改为了从数据库加载,并且也暴露了一些增删读写的方法,这些方法可以用在其他业务逻辑中。

这种方式也是携程Apollo使用的身份认证方式。

通过LDAP实现用户身份认证

企业内部一般使用LDAP进行身份管理。按照Spring Security官方文档,LDAP有两种集成方式:"Using Bind Authentication" 和 "Using Password Authentication"。

这里给出第一种方式的样例:

  • LDAP配置:(配置内容参考注释,并按照实际情况填写)
config:
  ldap:
    url: #ldap://the-company-xxx.us:390
    search-base: #ou=Employees,dc=staff,dc=the-company-xxx,dc=us
    user-dn: #cn=ldap_xxxx,cn=Persons,dc=staff,dc=the-company,dc=com
    search-filter: #'(xxxxx={0})'
    password: #xxxxxxx
  • 注入LDAP配置
@Data
@Configuration
@ConfigurationProperties(prefix = "config.ldap")
public class LdapProperties {

    private String url;

    private String searchBase;

    private String searchFilter;

    private String userDn;

    private String password;

}
  • 注入LdapContextSource、BindAuthenticator、LdapAuthenticationProvider
@Profile("ldap")
@Component
public class SecurityLdapConfiguration extends GeneralSecurityConfiguration {

    @Bean
    public LdapContextSource ldapContextSource(LdapProperties ldapProperties) {
        LdapContextSource contextSource = new LdapContextSource();
        contextSource.setUrl(ldapProperties.getUrl());
        contextSource.setUserDn(ldapProperties.getUserDn());
        contextSource.setPassword(ldapProperties.getPassword());
        contextSource.setPooled(false);
        contextSource.afterPropertiesSet();
        return contextSource;
    }

    @Bean
    public BindAuthenticator authenticator(BaseLdapPathContextSource contextSource, LdapProperties ldapProperties) {
        String searchBase = ldapProperties.getSearchBase();
        String filter = ldapProperties.getSearchFilter();
        FilterBasedLdapUserSearch search =
                new FilterBasedLdapUserSearch(searchBase, filter, contextSource);
        BindAuthenticator authenticator = new BindAuthenticator(contextSource);
        authenticator.setUserSearch(search);
        return authenticator;
    }

    @Bean
    public LdapAuthenticationProvider authenticationProvider(LdapAuthenticator authenticator) {
        return new LdapAuthenticationProvider(authenticator);
    }
}

得益于Spring Security非常精妙的API设计,LDAP的集成只需要注入几个简单的bean就可以轻松实现。

Remeber-Me

"记住我"是登录系统常见的功能,一般流程是:

  • 浏览器在网站A请求login,并在url上指定remember-me=true;
  • 服务器端执行登录过程的鉴权,登录成功后检查remember-me,如果存在,则生成一个带有效期的cookie,并返回给浏览器;
  • 如果浏览器重新打开,并再次进入网站A,会自动携带之前的cookie信息;
  • 服务器端自动分析cookie信息,如果cookie信息有效则为用户执行自动登录;否则清空cookie,并跳转登录流程。

Spring Security通过 RememberMeServices接口来抽象该逻辑,暴露的三个方法与上述流程高度吻合:

public interface RememberMeServices {
	
	Authentication autoLogin(HttpServletRequest request, HttpServletResponse response);

	void loginFail(HttpServletRequest request, HttpServletResponse response);

	void loginSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication successfulAuthentication);

}

所以,"记住我"的功能只需要简单实现RememberMeServices即可。更令人值得高兴的是,Spring Security已经为我们提供了两个非常完整的实现 TokenBasedRememberMeServices 和 PersistentTokenBasedRememberMeServices。

  • TokenBasedRememberMeServices 实现通过用户名、密码、失效时间和自定义key生成MD5编码,并将该编码、失效时间和用户名都保存在cookie中放回给浏览器。因此,只要下一次浏览器请求服务器端时携带cookie,服务器端能够根据cookie信息和密码信息进行自动身份认证。
  • PersistentTokenBasedRememberMeServices 略有不同,token是随机生成的,并且保存到repository中,并将token置于cookie返回给浏览器。因此自动登录过程的鉴权只需要简单比对token是否相同即可。至于repository的实现,用户可以自定义,或者使用默认实现 InMemoryTokenRepositoryImpl 和 JdbcTokenRepositoryImpl。前者将token保存在内存中,重启后丢失;而后者将token持久化到数据库中。

实现"记住我"

两种Service的实现思路都差不多,这里我们只看看TokenBasedRememberMeServices 的构造函数:

public TokenBasedRememberMeServices(String key, UserDetailsService userDetailsService) {
		super(key, userDetailsService);
	}

可见只需要传入一个自定义的key,以及一个UserDetailsService即可。而自定义key在此处,主要是想给密码加盐,增加MD5编码的随机强度。

于是,可以非常简单地对上述前两个样例进行补充,并实现"记住我":

  • 表单范例改造:
@Component
@Profile("form-remember")
public class SecurityFormLoginRememberConfiguration extends SecurityFormLoginConfiguration {

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void rememberMe(HttpSecurity http) throws Exception {
        http.rememberMe().rememberMeServices(new TokenBasedRememberMeServices("test_key", userDetailsService));
    }
}
  • 表单+JDBC范例改造
@Component
@Profile("jdbc-remember")
public class SecurityJdbcRememberConfiguration extends SecurityJdbcConfiguration {

    @Autowired
    private JdbcUserDetailsManager jdbcUserDetailsManager;

    @Override
    protected void rememberMe(HttpSecurity http) throws Exception {
        http.rememberMe().rememberMeServices(new TokenBasedRememberMeServices("test_key", jdbcUserDetailsManager));
    }
}

LDAP范例实现"记住我"

前两个范例都包含了实现UserDetailsService接口的Bean实例,所以基于TokenBasedRememberMeServices实现"记住我"功能都非常简单。

但是LDAP的范例并没有UserDetailsService接口的Bean实例,并且因为是通过LDAP认证,也并没有在服务器端保存用户密码信息。

所以,为了在LDAP范例上实现"记住我",我们需要进行拓展:

  • 拓展 LdapAuthenticationProvider,实现LDAP认证成功之后,保存用户信息到数据库;
  • 自定义UserDetailsService,实现从数据库加载用户信息
  • 注册TokenBasedRememberMeServices的bean实例(同上两个范例)

我们来看看具体实现细节:

第一步,拓展LdapAuthenticationProvider:

public class CustomLdapAuthenticationProvider extends LdapAuthenticationProvider {


    private CustomUserDetailsService userDetailsService;

    public CustomLdapAuthenticationProvider(LdapAuthenticator authenticator, CustomUserDetailsService userDetailsService) {
        super(authenticator);
        this.userDetailsService = userDetailsService;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        Authentication result = super.authenticate(authentication);
        this.userDetailsService.saveUser(authentication);
        return result;
    }
}

第二步,自定义CustomUserDetailsService:

@Slf4j
public class CustomUserDetailsService implements UserDetailsService {

    private UserRepository userRepository;

    private AuthorityRepository authorityRepository;

    private PasswordEncoder passwordEncoder;


    public CustomUserDetailsService(UserRepository userRepository, AuthorityRepository authorityRepository) {
        this.userRepository = userRepository;
        this.authorityRepository = authorityRepository;
        this.passwordEncoder = new BCryptPasswordEncoder();
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserPO userPo = userRepository.findUserByUsername(username);
        List<AuthorityPO> authorities = authorityRepository.findAuthorityPOSByUsername(username);

        List<SimpleGrantedAuthority> simpleGrantedAuthorities = authorities.stream().map(po -> new SimpleGrantedAuthority(po.getAuthority())).collect(Collectors.toList());
        return new User(userPo.getUsername(), userPo.getPassword(), simpleGrantedAuthorities);
    }

    @Transactional(rollbackOn = Exception.class)
    public void saveUser(Authentication authentication) {
        String principal = (String) authentication.getPrincipal();
        String credential = (String) authentication.getCredentials();
        UserPO newUserPO = new UserPO(principal, passwordEncoder.encode(credential));


        UserPO existedUser = userRepository.findUserByUsername(newUserPO.getUsername());
        if (existedUser != null) {
            if (StringUtils.equals(existedUser.getPassword(), newUserPO.getPassword())) {
                log.info("same user existed.[username={}]", existedUser.getUsername());
                return;
            }
        } else {
            userRepository.save(newUserPO);
        }


        Set<String> authorities = authentication.getAuthorities().stream().map(au -> au.getAuthority()).collect(Collectors.toSet());

        if (authorities.isEmpty()) {
            authorities.add("ROLE_user");
        }

        List<AuthorityPO> authorityPOS = authorities.stream().map(au -> new AuthorityPO(principal, au)).collect(Collectors.toList());
        authorityRepository.saveAll(authorityPOS);
    }
}

第三步,注册TokenBasedRememberMeServices的bean实例:

@Profile("ldap-remember")
@Component
public class SecurityLdapRememberConfiguration extends SecurityLdapConfiguration {

    @Autowired
    private RememberMeServices rememberMeServices;

    @Bean
    public LdapAuthenticationProvider authenticationProvider(LdapAuthenticator authenticator, CustomUserDetailsService userDetailsService) {
        return new CustomLdapAuthenticationProvider(authenticator, userDetailsService);
    }

    @Bean
    public CustomUserDetailsService customUserDetailsService(UserRepository userRepository, AuthorityRepository authorityRepository) {
        return new CustomUserDetailsService(userRepository, authorityRepository);
    }

    @Bean
    public RememberMeServices tokenBasedRememberMeServices(UserDetailsService userDetailsService) {
        return new TokenBasedRememberMeServices("test_key", userDetailsService);
    }

    @Override
    protected void rememberMe(HttpSecurity http) throws Exception {
        http.rememberMe().rememberMeServices(rememberMeServices);
    }
}

总结

本文主要探讨并实现了Spring Security的几个应用范例,核心思路是实现自定义的UserDetailService实例。接下来一篇文章,我会深入源码探讨具体的实现原理。

参考资料