Spring Security 学习03 — Basic Introduction Part III

Spring Security 5.1.4 RELEASE

2 核心服务

书接上文,上一篇介绍了AuthenticationManager, ProviderManager, AuthenticationProvider,并梳理了一下他们的作用以及关系。另外还介绍了Spring Security提供的一些AuthenticationProvider的实现。其中,在provider中用到了UserDetailsService来查询用户的具体信息,包括密码、分配的权限信息等,查询到的信息又会被包装成UserDetails返回。接下来我们就具体来看一下这两个接口。

2.2 UserDetailsService

大部分的provider实现都会用到UserDetailsServiceUserDetails,即使认证方式并不依赖用户名密码,但仍会用到UserDetails中包含的用户权限信息GrantedAuthority

UserDetailsService这个接口中,只声明了一个方法loadUserByUsername,通过用户名查找用户信息。

public interface UserDetailsService {
	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

Spring Security提供了该接口的一些基本实现

In-Memory Authentication

一般来说,用户信息都会从数据库中加载,但如果是在开发过程中使用,或者只是需要开发一个原型产品,开发者并不希望为此创建一个用户信息的数据库,便可采用这种In-Memory的方式来加载用户信息。

xml配置

一种配置的方式是使用安全命名空间中的user-service元素,配置如下:

<user-service id="userDetailsService">
	<!-- Password is prefixed with {noop} to indicate to DelegatingPasswordEncoder that
	NoOpPasswordEncoder should be used. This is not safe for production, but makes reading
	in samples easier. Normally passwords should be hashed using BCrypt -->
	<user name="jimi" password="{noop}jimispassword" authorities="ROLE_USER, ROLE_ADMIN" />
	<user name="bob" password="{noop}bobspassword" authorities="ROLE_USER" />
</user-service>

用户的信息也可以从外部文件进行加载:

<user-service id="userDetailsService" properties="users.properties"/>

在properties中,每一条用户信息的记录应当遵循以下格式:

username=password,grantedAuthority[,grantedAuthority][,enabled|disabled]

代码配置

相比于xml配置,个人更喜欢使用代码进行配置,这时候我们会用到的一个实现类便是InMemoryAuthenticationManager,这个类实现了接口UserDetailsManager,而这个接口继承了接口UserDetailsService

由于InMemoryAuthenticationManager源码还是很清楚的,便不再看源码,我们直接看一下如何用代码进行配置使用。

@EnableWebSecurity
public class WebSecurityConfig implements WebMvcConfigurer {

    @Bean
    public UserDetailsService userDetailsService() throws Exception {
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withDefaultPasswordEncoder().username("user").password("password").roles("USER").build());
        return manager;
    }
}

这里配置了一条用户信息,用户名、密码、角色分别为user, password, USER。

JdbcDaoImpl

这个实现是从数据库来获取用户的信息,内部使用了Spring JDBC来进行数据库读写,如果开发者使用了ORM,并且想复用自己写的映射关系的话,就需要自己实现一个定制的UserDetailsService了。

xml配置

<bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource">
    <property name="driverClassName" value="org.hsqldb.jdbcDriver"/>
    <property name="url" value="jdbc:hsqldb:hsql://localhost:9001"/>
    <property name="username" value="sa"/>
    <property name="password" value=""/>
</bean>

<bean id="userDetailsService" class="org.springframework.security.core.userdetails.jdbc.JdbcDaoImpl">
	<property name="dataSource" ref="dataSource"/>
</bean>

代码配置

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
	
    
	@Autowired
    // 数据库的连接信息,写在了application.yml配置文件里,当然也可以用代码配置。
    // 这里通过依赖注入,使用注解@Autowired将dataSource的对象实例注入到配置类中
	DataSource dataSource;
	
	@Bean
	@Override
    // 用dataSource配置一个JdbcDaoImpl作为UserDetailsService的实现
	protected UserDetailsService userDetailsService() {
		JdbcDaoImpl jdbcService = new JdbcDaoImpl();
		jdbcService.setDataSource(dataSource);
		return jdbcService;
	}
	
	@Override
    // 将UserDetailsService传入AuthenticationManager,并设置好PasswordEncoder
    // 从安全的角度出发,用户的密码在数据库中是hash后存储的,因此需要一个PasswordEncoder,在认证时候将用户提交的密码hash后再与数据库中的密码进行比较。
	protected void configure(AuthenticationManagerBuilder auth) throws Exception {
		auth.userDetailsService(userDetailsService()).passwordEncoder(passwordEncoder());
	}
	
	@Bean
    // 定义了一个要使用的PassordEncoder用来处理明文密码
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

2.3 UserDetails

UserDetails该接口的实现用于保存用户的基本信息,但是并不直接用于认证,接口中提供了获取账户相关信息的方法。

package org.springframework.security.core.userdetails;

import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;

import java.io.Serializable;
import java.util.Collection;

public interface UserDetails extends Serializable {
	
    // 返回用户的权限信息
	Collection<? extends GrantedAuthority> getAuthorities();

	// 返回用户密码
	String getPassword();
	
    // 返回用户名
	String getUsername();

	// 返回账号是否过期
	boolean isAccountNonExpired();

	// 返回账号是否被锁定
	boolean isAccountNonLocked();

	// 返回用户的密码是否过期
	boolean isCredentialsNonExpired();

	// 返回账号是否可用
	boolean isEnabled();
}

在Spring Security提供的UserDetailsService实现中使用的UserDetails的实现是一个User类,下方源码中可以看到,USER中设置了最基本的一些用户的属性,分别对应了UserDetails中提供的方法。让然,用户在实现自己的UserDetails接口时,也可以根据自己的需求,添加更多的信息,比如手机号、邮箱等。

public class User implements UserDetails, CredentialsContainer {

	private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

	private static final Log logger = LogFactory.getLog(User.class);

	// 类成员变量
	private String password;
	private final String username;
	private final Set<GrantedAuthority> authorities;
	private final boolean accountNonExpired;
	private final boolean accountNonLocked;
	private final boolean credentialsNonExpired;
	private final boolean enabled;
    
    // 类方法省略
    // ...

2.4 PasswordEncoder

PasswordEncoder接口的实现主要用于对用户的密码进行一次“单向变形”后更安全的进行存储。在用户认证时候,在比较用户输入的明文密码和数据库存储的密码时候,也会用到PasswordEncoder

单向变形( one way transformation )是官方文档给出的说法,一般在实际开发中采用的实践是,对用户的密码进行加盐哈希后保存在数据库,当然开发者在实现自己的PasswordEncoder时候,也可以设计自己的算法。

DelegatingPasswordEncoder

在Spring Security 5.0 之前,默认使用的PasswordEncoderNoOpPasswordEncoder,其使用的是明文密码,即不对密码做任何处理。如果现在想要使用更安全的密码处理,有更新更安全的BCryptPasswordEncoder可供使用,然而虽然安全性提高了,在实际应用中,依然会面临以下问题:

  • 有很多老系统使用比较老的密码处理方法,要迁移到新的方法比较困难。
  • 还是会不断有更新更安全的密码处理方法出现。
  • Spring Security框架也不能频繁做大更新。

为了解决以上的问题,Spring Security引入了一种委派机制DelegatingPasswordEncoder,能够提供一下功能:

  • 确保能够使用目前推荐的密码处理方法进行密码存储
  • 支持验证现代或传统的密码格式
  • 允许在将来升级新的密码处理方式

既然这个实现在官方文档中拿了出来讲,那我们不妨也来看一下这个实现的源码。

package org.springframework.security.crypto.password;

import java.util.HashMap;
import java.util.Map;

public class DelegatingPasswordEncoder implements PasswordEncoder {
   
    /* 
     * 为了同时支持使用不同的PasswordEncoder,处理过后的密码保存格式如:
     * {bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
     * {}表示使用哪种PasswordEncoder的一个id,后面为单向加密过后的密码。
     * 比如当进行密码匹配时,识别到{bcrypt}后,就会把任务委派给BCryptPasswordEncoder,对用户输入的密码处理后与保存的密码进行比较。
     */
	private static final String PREFIX = "{";
	private static final String SUFFIX = "}";
	private final String idForEncode;
	private final PasswordEncoder passwordEncoderForEncode;
	private final Map<String, PasswordEncoder> idToPasswordEncoder;
	private PasswordEncoder defaultPasswordEncoderForMatches = new UnmappedIdPasswordEncoder();

	/**
	 * 生成一个DelegatingPasswordEncoder实例
	 * 构造函数中传入的idForEncode确定了当前使用了PasswordEncoder类型,之后的encode函数也会用使用该PasswordEncoder
	 * 传入的idToPasswordEncoder是一个字典,包含了所有支持的PasswordEncoder
	 */
	public DelegatingPasswordEncoder(String idForEncode,
		Map<String, PasswordEncoder> idToPasswordEncoder) {
		if (idForEncode == null) {
			throw new IllegalArgumentException("idForEncode cannot be null");
		}
		if (!idToPasswordEncoder.containsKey(idForEncode)) {
			throw new IllegalArgumentException("idForEncode " + idForEncode + "is not found in idToPasswordEncoder " + idToPasswordEncoder);
		}
		for (String id : idToPasswordEncoder.keySet()) {
			if (id == null) {
				continue;
			}
			if (id.contains(PREFIX)) {
				throw new IllegalArgumentException("id " + id + " cannot contain " + PREFIX);
			}
			if (id.contains(SUFFIX)) {
				throw new IllegalArgumentException("id " + id + " cannot contain " + SUFFIX);
			}
		}
		this.idForEncode = idForEncode;
		this.passwordEncoderForEncode = idToPasswordEncoder.get(idForEncode);
		this.idToPasswordEncoder = new HashMap<>(idToPasswordEncoder);
	}

	/**
	 * 设置一个默认使用的PasswordEncoder,如果id无法匹配到一个可用的PasswordEncoder,则使用默认的。
	 */
	public void setDefaultPasswordEncoderForMatches(
		PasswordEncoder defaultPasswordEncoderForMatches) {
		if (defaultPasswordEncoderForMatches == null) {
			throw new IllegalArgumentException("defaultPasswordEncoderForMatches cannot be null");
		}
		this.defaultPasswordEncoderForMatches = defaultPasswordEncoderForMatches;
	}

    /**
     * 将密码单向加密为 {idForEncode}encodedPassword 的形式返回。
     */
	@Override
	public String encode(CharSequence rawPassword) {
		return PREFIX + this.idForEncode + SUFFIX + this.passwordEncoderForEncode.encode(rawPassword);
	}

    /**
     * 将明文密码与加密后的密码比较
     * 当prefixEncodedPassword中的id无法找到对应的PasswordEncode则会使用默认的defaultPasswordEncoderForMatches来处理
     * 在这里,默认的defaultPasswordEncoderForMatches = new UnmappedIdPasswordEncoder(),会抛出异常提示“不支持的encode方式”
     */
	@Override
	public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
		if (rawPassword == null && prefixEncodedPassword == null) {
			return true;
		}
		String id = extractId(prefixEncodedPassword);
		PasswordEncoder delegate = this.idToPasswordEncoder.get(id);
		if (delegate == null) {
			return this.defaultPasswordEncoderForMatches
				.matches(rawPassword, prefixEncodedPassword);
		}
		String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
		return delegate.matches(rawPassword, encodedPassword);
	}

    // 从{idForEncode}encodedPassword中提取出idForEncode
	private String extractId(String prefixEncodedPassword) {
		if (prefixEncodedPassword == null) {
			return null;
		}
		int start = prefixEncodedPassword.indexOf(PREFIX);
		if (start != 0) {
			return null;
		}
		int end = prefixEncodedPassword.indexOf(SUFFIX, start);
		if (end < 0) {
			return null;
		}
		return prefixEncodedPassword.substring(start + 1, end);
	}

    
    // 判断当前的加密方式id是否与传入的encodedPassword的加密方式一样
	@Override
	public boolean upgradeEncoding(String encodedPassword) {
		String id = extractId(encodedPassword);
		return !this.idForEncode.equalsIgnoreCase(id);
	}
    
    // 从从{idForEncode}encodedPassword中提取出encodedPassword的部分
	private String extractEncodedPassword(String prefixEncodedPassword) {
		int start = prefixEncodedPassword.indexOf(SUFFIX);
		return prefixEncodedPassword.substring(start + 1);
	}

	/**
	 * 默认的PasswordEncoder,当id找不到对应PasswordEncoder时,抛出异常。
	 */
	private class UnmappedIdPasswordEncoder implements PasswordEncoder {

		@Override
		public String encode(CharSequence rawPassword) {
			throw new UnsupportedOperationException("encode is not supported");
		}

		@Override
		public boolean matches(CharSequence rawPassword,
			String prefixEncodedPassword) {
			String id = extractId(prefixEncodedPassword);
			throw new IllegalArgumentException("There is no PasswordEncoder mapped for the id \"" + id + "\"");
		}
	}
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值