spring security rememberMe 提升安全性 讲解 !

目录

一、介绍

二、原理分析

三、源码详情

3.1.processAutoLoginCookie 解码认证方法

3.2 onLoginSuccess 生成cookie 方法

四、基于内存的令牌实现

五、持久化令牌

5.1.结构分析

5.2 PersistentTokenRepository

5.3 JdbcTokenRepositoryImpl 源码

5.4 使用步骤

1.引入依赖

2.配置数据源

3.配置持久化令牌


一、介绍

        默认使用的remebermeService 的实现类 TokenBasedRememberMeServices 的 他做的比较简单,对用户名 、过期时间+生成令牌 进行Base64的加密,在放入cookie,还又就是解码比较认证,没有什么安全措施,如果cookie被外部拦截到,那么就会肆无忌惮的进行攻击服务器,是比较不安全的。spring Security 也给我们提供了比较安全的实现方式,使用 PersistentTokenBasedRememberMeServices 进行remeberme的功能实现。

PersistentTokenBasedRememberMeServices 提升安全性的点:

  1. cookie 里只存放了两个数据 一个 series和 token ,没有存放用户名等信息,将主要信息存放到了服务器中。
  2. 认证成功后返回cookie 值 是 series 和 token ,两个值是通过Base64 编码后的。
  3. 在jsession过期后,认证成功后,每次都会生成新的 token 和 date ,并且更新到前端,从而提升安全性。

二、原理分析

  1. 不同 TokenBasedRemornberMeServices 中的 processAutologinCookie 方法,这里cookieTokens 数组的长度为2,第一项是servies,第二项是token 。
  2. 从cookieTokens 数组中分别提取出series 和 token ,然后根据 series 去内存中查询出一个 PersistentRememberMeToken 对象。如果查询出来的对象为null,表示内存中并没有series 对应的值,本次自动登录失败。如果查询出来的token和从 cookieTokens 中解析出来的 token不相同,说明自动登录令牌已经泄露(恶意用户利用令牌登录后,内存中的token变了),此时移除当前用户的所有自动登录记录并抛出异常。
  3. 根据数据库中查询出来的结果判断令牌是否过期,如果过期就抛出异常。
  4. 生成一个新的PersistentRememberMeToken 对象,用户名和 series 不变,token 重新生成,date 也是使用当前时间。newToken生成后,根据series 去修改内存中的token 和 date (即每次自动登录后都会产生新的 token 和 date)
  5. 调用 addCookie 方法添加 Cookie,在addCookie 方法中,会调用到我们前面所说的setCookie方法,但是要注意第一个数组参数中只有两项:series和 token (即返回到前端的令牌是通过对series 和 token 进行 Base64 编码得到的)
  6. 最后将根据用户名查询用户对象并返回。

三、源码详情

3.1.processAutoLoginCookie 解码认证方法

protected UserDetails processAutoLoginCookie(String[] cookieTokens,
			HttpServletRequest request, HttpServletResponse response) {

		// 判断当前的cookie的 长度 里面应该是series 和 token 
		if (cookieTokens.length != 2) {
			throw new InvalidCookieException("Cookie token did not contain " + 2
					+ " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
		}

		// 获取 series 和 token 

		final String presentedSeries = cookieTokens[0];
		final String presentedToken = cookieTokens[1];

		//根据series 获取认证对象
	/**

		这种方式是将生成敏感信息都写入到内存当中。
		如果只是到这里的话那么只需要不破坏cookie,最终还是会认证成功。
		在这方式他会在认证成功后重新生成一个令牌并去更新之前的令牌
	*/
		PersistentRememberMeToken token = tokenRepository
				.getTokenForSeries(presentedSeries);
		// 如果认证对象为空 则表示没有没有查询到认证信息
		if (token == null) {
			// No series match, so we can't authenticate using this cookie
			throw new RememberMeAuthenticationException(
					"No persistent token found for series id: " + presentedSeries);
		}

		// 拿着传过来的 series 和 解码后的series去比较
		// We have a match for this user/series combination
		if (!presentedToken.equals(token.getTokenValue())) {
			// Token doesn't match series value. Delete all logins for this user and throw			
			// 表示不正确,进行移除和抛出异常
			// an exception to warn them.
			tokenRepository.removeUserTokens(token.getUsername());

			throw new CookieTheftException(
					messages.getMessage(
							"PersistentTokenBasedRememberMeServices.cookieStolen",
							"Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
		}

		// 判断是否过期
		if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System
				.currentTimeMillis()) {
			throw new RememberMeAuthenticationException("Remember-me login has expired");
		}

		// Token also matches, so login is valid. Update the token value, keeping the
		// *same* series number.
		if (logger.isDebugEnabled()) {
			logger.debug("Refreshing persistent login token for user '"
					+ token.getUsername() + "', series '" + token.getSeries() + "'");
		}

		// 生成新的 token 
		PersistentRememberMeToken newToken = new PersistentRememberMeToken(
				token.getUsername(), token.getSeries(), generateTokenData(), new Date());

		try {
			// 进行series 的更新 
			tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(),
					newToken.getDate());
			addCookie(newToken, request, response);
		}
		catch (Exception e) {
			logger.error("Failed to update token: ", e);
			throw new RememberMeAuthenticationException(
					"Autologin failed due to data access problem");
		}

		return getUserDetailsService().loadUserByUsername(token.getUsername());
	}

对以上代码的剖析

  1. PersistentRememberMeToken 对象
    1. 他就是一个实体对象,存放这认证的信息 和时间信息
    2. public class PersistentRememberMeToken {
      	private final String username;
      	private final String series;
      	private final String tokenValue;
      	private final Date date;
      
      	public PersistentRememberMeToken(String username, String series, String tokenValue,
      			Date date) {
      		this.username = username;
      		this.series = series;
      		this.tokenValue = tokenValue;
      		this.date = date;
      	}
      
      	public String getUsername() {
      		return username;
      	}
      
      	public String getSeries() {
      		return series;
      	}
      
      	public String getTokenValue() {
      		return tokenValue;
      	}
      
      	public Date getDate() {
      		return date;
      	}
      }

  2. getTokenForSeries 根据series获取认证对象
    1. 默认调用的 InMemoryTokenRepositoryImpl 基于内存
    2. 其实基于内存的就是将数据放到一个Map中,根据 series 从map中取PersistentRememberMeToken 对象
    3. 	private final Map<String, PersistentRememberMeToken> seriesTokens = new HashMap<>();
      
      	public synchronized PersistentRememberMeToken getTokenForSeries(String seriesId) {
      		return seriesTokens.get(seriesId);
      	}

    4. removeUserTokens 比较不通过是移除token
      1. 就是从map中移除
      2. public synchronized void removeUserTokens(String username) {
        		Iterator<String> series = seriesTokens.keySet().iterator();
        
        		while (series.hasNext()) {
        			String seriesId = series.next();
        
        			PersistentRememberMeToken token = seriesTokens.get(seriesId);
        
        			if (username.equals(token.getUsername())) {
        				series.remove();
        			}
        		}
        	}

  3. 生成 token 和 series 的方法

    1. 通过 random 对 byte 数组处理,在通过Base64 编码。
    2. 	protected String generateSeriesData() {
      		// 16位
      		byte[] newSeries = new byte[seriesLength];
      		random.nextBytes(newSeries);
      		return new String(Base64.getEncoder().encode(newSeries));
      	}
      
      	protected String generateTokenData() {
      		// 16 位
      		byte[] newToken = new byte[tokenLength];
      		random.nextBytes(newToken);
      		return new String(Base64.getEncoder().encode(newToken));
      	}

  4. 生成 token 和 series 的方法
    1. 通过 random 对 byte 数组处理,在通过Base64 编码。
    2. 	protected String generateSeriesData() {
      		// 16位
      		byte[] newSeries = new byte[seriesLength];
      		random.nextBytes(newSeries);
      		return new String(Base64.getEncoder().encode(newSeries));
      	}
      
      	protected String generateTokenData() {
      		// 16 位
      		byte[] newToken = new byte[tokenLength];
      		random.nextBytes(newToken);
      		return new String(Base64.getEncoder().encode(newToken));
      	}

  5. updateToken 更新内存中的 认证信息
    1. 根据series进行map中替换
    2. 	public synchronized void updateToken(String series, String tokenValue, Date lastUsed) {
      		PersistentRememberMeToken token = getTokenForSeries(series);
      
      		PersistentRememberMeToken newToken = new PersistentRememberMeToken(
      				token.getUsername(), series, tokenValue, new Date());
      
      		// Store it, overwriting the existing one.
      		seriesTokens.put(series, newToken);
      	}

  6. addCookie 添加到cookie里
    1. // 添加cookie 
      private void addCookie(PersistentRememberMeToken token, HttpServletRequest request,
      			HttpServletResponse response) {
      	// 调用方法进行设置	
      	setCookie(new String[] { token.getSeries(), token.getTokenValue() },
      				getTokenValiditySeconds(), request, response);
      }
      
      /**
      	 * Sets the cookie on the response.
      	 *
      	 * By default a secure cookie will be used if the connection is secure. You can set
      	 * the {@code useSecureCookie} property to {@code false} to override this. If you set
      	 * it to {@code true}, the cookie will always be flagged as secure. By default the cookie
      	 * will be marked as HttpOnly.
      	 *
      	 * @param tokens the tokens which will be encoded to make the cookie value.
      	 * @param maxAge the value passed to {@link Cookie#setMaxAge(int)}
      	 * @param request the request
      	 * @param response the response to add the cookie to.
      	 */
      	protected void setCookie(String[] tokens, int maxAge, HttpServletRequest request,
      			HttpServletResponse response) {
      		// 将值进行编码
      		String cookieValue = encodeCookie(tokens);
      		
      		Cookie cookie = new Cookie(cookieName, cookieValue);
      		// 设置存活时间 默认是 2周
      		cookie.setMaxAge(maxAge);
      		// 设置cookie路径
      		cookie.setPath(getCookiePath(request));
      		// 区域
      		if (cookieDomain != null) {
      			cookie.setDomain(cookieDomain);
      		}
      		// 判断存活时间
      		if (maxAge < 1) {
      			cookie.setVersion(1);
      		}
      
      		if (useSecureCookie == null) {
      			cookie.setSecure(request.isSecure());
      		}
      		else {
      			cookie.setSecure(useSecureCookie);
      		}
      
      		cookie.setHttpOnly(true);
      
      		response.addCookie(cookie);
      	}

3.2 onLoginSuccess 生成cookie 方法

	protected void onLoginSuccess(HttpServletRequest request,
			HttpServletResponse response, Authentication successfulAuthentication) {
		String username = successfulAuthentication.getName();

		logger.debug("Creating new persistent login for user " + username);
		// 调用本类的 生成 series 和 token 的方法 直接创建对象 
		PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(
				username, generateSeriesData(), generateTokenData(), new Date());
		try {
			// 调用创建方法根据 series 放入map 中 
			tokenRepository.createNewToken(persistentToken); 
			// 添加cookie 写回操作 
			addCookie(persistentToken, request, response);
		}
		catch (Exception e) {
			logger.error("Failed to save persistent token ", e);
		}
	}

底层代码解析

  1. createNewToken 放入到map中
  2. 	private final Map<String, PersistentRememberMeToken> seriesTokens = new HashMap<>();
    
    	public synchronized void createNewToken(PersistentRememberMeToken token) {
    		PersistentRememberMeToken current = seriesTokens.get(token.getSeries());
    
    		if (current != null) {
    			throw new DataIntegrityViolationException("Series Id '" + token.getSeries()
    					+ "' already exists!");
    		}
    
    		seriesTokens.put(token.getSeries(), token);
    	}

    以上就是PersistentTokenBasedRememberMeServices 的核心源码的分析和讲解 。

四、基于内存的令牌实现

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
  /**
     * 配置安全策略
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/index").permitAll()
                .anyRequest()
                .authenticated()
                .and()
                .rememberMe() // 开启rememberMe 功能
                .rememberMeServices(rememberMeServices()) // 更换记住我的方式的Service 层
//                .alwaysRemember(true) // 总是记住我
                .key(UUID.randomUUID().toString()) // 自定义 key 值
                .and()
                .formLogin()
                .and()
                .csrf()
                .disable();

    }
    /**
     * 创建基于安全性高的
     */
    @Bean
    public RememberMeServices rememberMeServices (){
        // 三个参数:1.key  2. 数据库方式 3.认证信息的存储方式
        return new PersistentTokenBasedRememberMeServices(UUID.randomUUID().toString(),userDetailsService(),new InMemoryTokenRepositoryImpl());
    }

五、持久化令牌

        默认的 PersistentTokenBasedRememberMeServices 是基于内存的,服务器已宕机数据就会丢失,安全性和容错率不太好,需要将他更换为基于数据库的实现。

5.1.结构分析

        af7a9f343c6e5fa583db1cb93bd3d733.png

        默认是基于内存,但是我们可以通过自定义 PersistentTokenBasedRememberMeServices 通过他的构造方法来替换成基于数据库的

5.2 PersistentTokenRepository

        PersistentTokenRepository 是一个接口,它的作用就是存取 生成的 认证信息的。

public interface PersistentTokenRepository {
	// 添加 认证信息
	void createNewToken(PersistentRememberMeToken token);
	// 修改认证信息
	void updateToken(String series, String tokenValue, Date lastUsed);
	// 获取认证信息
	PersistentRememberMeToken getTokenForSeries(String seriesId);
	// 移除认证信息
	void removeUserTokens(String username);

}

他的实现有基于内存和 数据库的两个实现类

5.3 JdbcTokenRepositoryImpl 源码

        这个类已经将类的结构的sql写好,直接拿来用就可以 。默认是通过 JDBCTemplate进行实现的,所以需要引入对应的 jdbc 相关依赖。

/**
 * JDBC based persistent login token repository implementation.
 *
 * @author Luke Taylor
 * @since 2.0
 */
public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements
		PersistentTokenRepository {
	// ~ Static fields/initializers
	// =====================================================================================

	/** Default SQL for creating the database table to store the tokens */
	public static final String CREATE_TABLE_SQL = "create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, "
			+ "token varchar(64) not null, last_used timestamp not null)";
	/** The default SQL used by the <tt>getTokenBySeries</tt> query */
	public static final String DEF_TOKEN_BY_SERIES_SQL = "select username,series,token,last_used from persistent_logins where series = ?";
	/** The default SQL used by <tt>createNewToken</tt> */
	public static final String DEF_INSERT_TOKEN_SQL = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";
	/** The default SQL used by <tt>updateToken</tt> */
	public static final String DEF_UPDATE_TOKEN_SQL = "update persistent_logins set token = ?, last_used = ? where series = ?";
	/** The default SQL used by <tt>removeUserTokens</tt> */
	public static final String DEF_REMOVE_USER_TOKENS_SQL = "delete from persistent_logins where username = ?";

	// ~ Instance fields
	// ================================================================================================

	private String tokensBySeriesSql = DEF_TOKEN_BY_SERIES_SQL;
	private String insertTokenSql = DEF_INSERT_TOKEN_SQL;
	private String updateTokenSql = DEF_UPDATE_TOKEN_SQL;
	private String removeUserTokensSql = DEF_REMOVE_USER_TOKENS_SQL;
	private boolean createTableOnStartup;

	protected void initDao() {
		if (createTableOnStartup) {
			getJdbcTemplate().execute(CREATE_TABLE_SQL);
		}
	}

	public void createNewToken(PersistentRememberMeToken token) {
		getJdbcTemplate().update(insertTokenSql, token.getUsername(), token.getSeries(),
				token.getTokenValue(), token.getDate());
	}

	public void updateToken(String series, String tokenValue, Date lastUsed) {
		getJdbcTemplate().update(updateTokenSql, tokenValue, lastUsed, series);
	}

	/**
	 * Loads the token data for the supplied series identifier.
	 *
	 * If an error occurs, it will be reported and null will be returned (since the result
	 * should just be a failed persistent login).
	 *
	 * @param seriesId
	 * @return the token matching the series, or null if no match found or an exception
	 * occurred.
	 */
	public PersistentRememberMeToken getTokenForSeries(String seriesId) {
		try {
			return getJdbcTemplate().queryForObject(tokensBySeriesSql,
					(rs, rowNum) -> new PersistentRememberMeToken(rs.getString(1), rs
							.getString(2), rs.getString(3), rs.getTimestamp(4)), seriesId);
		}
		catch (EmptyResultDataAccessException zeroResults) {
			if (logger.isDebugEnabled()) {
				logger.debug("Querying token for series '" + seriesId
						+ "' returned no results.", zeroResults);
			}
		}
		catch (IncorrectResultSizeDataAccessException moreThanOne) {
			logger.error("Querying token for series '" + seriesId
					+ "' returned more than one value. Series" + " should be unique");
		}
		catch (DataAccessException e) {
			logger.error("Failed to load token for series " + seriesId, e);
		}

		return null;
	}

	public void removeUserTokens(String username) {
		getJdbcTemplate().update(removeUserTokensSql, username);
	}

	/**
	 * Intended for convenience in debugging. Will create the persistent_tokens database
	 * table when the class is initialized during the initDao method.
	 *
	 * @param createTableOnStartup set to true to execute the
	 */
	public void setCreateTableOnStartup(boolean createTableOnStartup) {
		this.createTableOnStartup = createTableOnStartup;
	}
}

5.4 使用步骤

1.引入依赖

<dependency> 
	<groupId>com.alibaba</groupId> 
	<artifactId>druid</artifactId> 
	<version>1.2.8</version>
</dependency> 

<dependency> 
	<groupId>mysql</groupId> 
	<artifactId>mysql-connector-java</artifactId> 
	<version>5.1.38</version>
</dependency> 

<dependency>
	<groupId>org.mybatis.spring.boot</groupId>
	<artifactId>mybatis-spring-boot-starter</artifactId>
	<version>2.2.0</version>
</dependency>

2.配置数据源

spring.thymeleaf.cache=false
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/security?
characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=root
mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.type-aliases-package=com.entity

3.配置持久化令牌

@Configuration(proxyBeanMethods = false)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * 数据源
     */
    private final DataSource dataSource;

    public WebSecurityConfig(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    /**
     * 定义数据库访问层
     * @return
     */
    @Override
    protected UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
        inMemoryUserDetailsManager.createUser(User.withUsername("zs").password("{noop}123").roles("admin").build());
        return inMemoryUserDetailsManager ;
    }


    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService());
    }

    /**
     * 配置安全策略
     * 启动项⽬并查看数据库
     * 注意::启动项⽬会⾃动创建⼀个表,,⽤来保存记住我的 token token 信息
     * 再次测试记住我
     * 在测试发现即使服务器重新启动,依然可以⾃动登录。
     * ⾃定义记住我
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/index").permitAll()
                .mvcMatchers("/test").rememberMe() // 设置指定路径支持记住我
                .anyRequest()
                .authenticated()
                .and()
                .rememberMe() // 开启rememberMe 功能
//                .rememberMeServices(rememberMeServices()) // 更换记住我的方式的Service 层
                .tokenRepository(persistentTokenRepository()) // 修改底层数据源默认就会修改 remeberMeService
//                .alwaysRemember(true) // 总是记住我
                .key(UUID.randomUUID().toString()) // 自定义 key 值
                .and()
                .formLogin()
                .and()
                .csrf()
                .disable();

    }
    /**
     * 创建基于安全性高的 基于内存
     */
    @Bean
    public RememberMeServices rememberMeServices (){
        // 三个参数:1.key  2. 数据库方式 3.认证信息的存储方式
        return new PersistentTokenBasedRememberMeServices(UUID.randomUUID().toString(),userDetailsService(),new InMemoryTokenRepositoryImpl());
    }

    /**
     * 基于持久化方式
     */

    @Bean
    public PersistentTokenRepository persistentTokenRepository (){
         JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
         jdbcTokenRepository.setDataSource(dataSource);
         jdbcTokenRepository.setCreateTableOnStartup(true); // 是否启动时创建表结构,建议关闭,因为每次都会重新创建
        return jdbcTokenRepository;
    }

以上就是 全部内容!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值