Spring Security: user service UserDetailsService source code analysis

Posted by Matth_S on Tue, 04 Jan 2022 21:27:20 +0100

In the previous blog, the blogger introduced the UserDetails interface of Spring Security and its implementation. Spring Security uses the UserDetails instance (the instance of the implementation class) to represent the user. When the client authenticates (provide the user name and password), Spring Security will obtain the corresponding UserDetails instance (the same user name) through the user service (UserDetailsService interface and Its Implementation). If the UserDetails instance exists and matches the information entered by the client, the verification is successful, otherwise the verification fails. For more information about the UserDetails interface and its implementation, please see the following blog:

UserDetailsService

UserDetailsService interface source code:

package org.springframework.security.core.userdetails;

/**
 * Core interface for loading user specific data
 * It acts as the user's DAO layer (user data access layer) in the whole framework
 */
public interface UserDetailsService {
	/**
	 * Locate users by user name
	 */
	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

The UserDetailsService interface defines only one method, that is, the method to find the UserDetails instance through the user name. Therefore, the subclass can have various implementations, which can be based on the heap memory of the JVM (for example, using ConcurrentHashMap to store the UserDetails instance), or based on the middleware (such as Mysql and Redis), or a mixed mode of the two, Implementation can be customized according to requirements. The inheritance and implementation relationship of UserDetailsService interface is shown in the following figure:

UserDetailsManager

UserDetailsManager interface source code (inheriting UserDetailsService interface):

package org.springframework.security.provisioning;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

/**
 * UserDetailsService Provides the ability to create new users and update existing users
 */
public interface UserDetailsManager extends UserDetailsService {

	/**
	 * Create a new user using the UserDetails instance provided
	 */
	void createUser(UserDetails user);

	/**
	 * Update the specified user
	 */
	void updateUser(UserDetails user);

	/**
	 * Delete user with given user name
	 */
	void deleteUser(String username);

	/**
	 * Modify the password of the current user
	 */
	void changePassword(String oldPassword, String newPassword);

	/**
	 * Check if there is a user with the given user name
	 */
	boolean userExists(String username);
}

Obviously, the UserDetailsManager interface is an extension of the UserDetailsService interface, providing the ability to create new users and update existing users.

JdbcDaoImpl

The structure of JdbcDaoImpl class is shown in the following figure:

Use JDBC to retrieve user details (user name, password, enable flag, and permissions) from the database. Suppose there is a default database schema (two tables users and authorities).

create table users(
	username varchar_ignorecase(50) not null primary key,
	password varchar_ignorecase(500) not null,
	enabled boolean not null
);

create table authorities (
	username varchar_ignorecase(50) not null,
	authority varchar_ignorecase(50) not null,
	constraint fk_authorities_users foreign key(username) references users(username)
);
create unique index ix_auth_username on authorities (username,authority);

If the database schema is different from the default, you can set the usersByUsernameQuery and authorityByUsernameQuery properties to match the database settings, otherwise their values are the default SQL.

	public JdbcDaoImpl() {
		this.usersByUsernameQuery = DEF_USERS_BY_USERNAME_QUERY;
		this.authoritiesByUsernameQuery = DEF_AUTHORITIES_BY_USERNAME_QUERY;
		this.groupAuthoritiesByUsernameQuery = DEF_GROUP_AUTHORITIES_BY_USERNAME_QUERY;
	}

You can enable support for group permissions by setting the enableGroups property to true (you can also set enableAuthorities to false to disable permission loading directly). In this way, permissions are assigned to groups, and users' permissions are determined according to the group to which they belong. The end result is the same (a UserDetails instance containing a set of grantedauthorities is loaded). When using groups, tables groups and groups are required_ Members and groups_ authorities.

create table groups (
	id bigint generated by default as identity(start with 0) primary key,
	group_name varchar_ignorecase(50) not null
);

create table group_authorities (
	group_id bigint not null,
	authority varchar(50) not null,
	constraint fk_group_authorities_group foreign key(group_id) references groups(id)
);

create table group_members (
	id bigint generated by default as identity(start with 0) primary key,
	username varchar(50) not null,
	group_id bigint not null,
	constraint fk_group_members_group foreign key(group_id) references groups(id)
);

For the default query of loading group permissions, refer to DEF_GROUP_AUTHORITIES_BY_USERNAME_QUERY . Similarly, you can customize it by setting the groupAuthoritiesByUsernameQuery property.

JdbcDaoImpl class source code (implements the UserDetailsService interface and provides the basic implementation of obtaining user data using JDBC. The following code deletes the content mentioned above):

public class JdbcDaoImpl extends JdbcDaoSupport
		implements UserDetailsService, MessageSourceAware {

   /**
	 * Allow subclasses to add their own granted permissions to the permission list of the UserDetail instance
	 */
	protected void addCustomAuthorities(String username,
			List<GrantedAuthority> authorities) {
	}

    // Load the user's core logic through the user name and complete it by using other methods
	@Override
	public UserDetails loadUserByUsername(String username)
			throws UsernameNotFoundException {
		List<UserDetails> users = loadUsersByUsername(username);

		if (users.size() == 0) {
			this.logger.debug("Query returned no results for user '" + username + "'");

			throw new UsernameNotFoundException(
					this.messages.getMessage("JdbcDaoImpl.notFound",
							new Object[] { username }, "Username {0} not found"));
		}

		UserDetails user = users.get(0); 

		Set<GrantedAuthority> dbAuthsSet = new HashSet<>();

		if (this.enableAuthorities) {
			dbAuthsSet.addAll(loadUserAuthorities(user.getUsername()));
		}

		if (this.enableGroups) {
			dbAuthsSet.addAll(loadGroupAuthorities(user.getUsername()));
		}

		List<GrantedAuthority> dbAuths = new ArrayList<>(dbAuthsSet);

		addCustomAuthorities(user.getUsername(), dbAuths);

		if (dbAuths.size() == 0) {
			this.logger.debug("User '" + username
					+ "' has no authorities and will be treated as 'not found'");

			throw new UsernameNotFoundException(this.messages.getMessage(
					"JdbcDaoImpl.noAuthority", new Object[] { username },
					"User {0} has no GrantedAuthority"));
		}

		return createUserDetails(username, user, dbAuths);
	}

	/**
	 * Get the UserDetails instance list by executing SQL (usersByUsernameQuery)
	 * Usually there should be only one matching user
	 */
	protected List<UserDetails> loadUsersByUsername(String username) {
		return getJdbcTemplate().query(this.usersByUsernameQuery,
				new String[] { username }, (rs, rowNum) -> {
					String username1 = rs.getString(1);
					String password = rs.getString(2);
					boolean enabled = rs.getBoolean(3);
					return new User(username1, password, enabled, true, true, true,
							AuthorityUtils.NO_AUTHORITIES);
				});
	}

	/**
	 * Load permissions by executing SQL (authorityByUsernameQuery)
	 */
	protected List<GrantedAuthority> loadUserAuthorities(String username) {
		return getJdbcTemplate().query(this.authoritiesByUsernameQuery,
				new String[] { username }, (rs, rowNum) -> {
					String roleName = JdbcDaoImpl.this.rolePrefix + rs.getString(2);

					return new SimpleGrantedAuthority(roleName);
				});
	}

	/**
	 * Load permissions by executing SQL (group authoritiesbyusernamequery)
	 */
	protected List<GrantedAuthority> loadGroupAuthorities(String username) {
		return getJdbcTemplate().query(this.groupAuthoritiesByUsernameQuery,
				new String[] { username }, (rs, rowNum) -> {
					String roleName = getRolePrefix() + rs.getString(3);

					return new SimpleGrantedAuthority(roleName);
				});
	}

	/**
	 * You can override the UserDetails instance returned by the loadUserByUsername method
	 */
	protected UserDetails createUserDetails(String username,
			UserDetails userFromUserQuery, List<GrantedAuthority> combinedAuthorities) {
		String returnUsername = userFromUserQuery.getUsername();

		if (!this.usernameBasedPrimaryKey) {
			returnUsername = username;
		}

		return new User(returnUsername, userFromUserQuery.getPassword(),
				userFromUserQuery.isEnabled(), userFromUserQuery.isAccountNonExpired(),
				userFromUserQuery.isCredentialsNonExpired(), userFromUserQuery.isAccountNonLocked(), combinedAuthorities);
	}

	/**
	 * Allows you to specify a default role prefix
	 * If it is set to a non null value, it is automatically added to any role read from the database
	 * For example, it can be used to add the ROLE that other Spring Security classes add to the ROLE name by default_ Prefix in case the prefix does not exist in the database
	 */
	public void setRolePrefix(String rolePrefix) {
		this.rolePrefix = rolePrefix;
	}
}

The JdbcDaoImpl class provides a basic implementation for obtaining user data using JDBC.

JdbcUserDetailsManager

The JdbcUserDetailsManager class inherits the JdbcDaoImpl class (provides the basic implementation of obtaining user data using JDBC), and implements two interfaces, UserDetailsManager and GroupManager. The UserDetailsManager interface provides the ability to create new users and update existing users. The GroupManager interface allows you to manage group permissions and their members. It is usually used to supplement the functions of UserDetailsManager in the following cases:

  • Organize the permissions granted by the application into groups instead of directly mapping users to roles.
  • In this case, the user is assigned to the group and gets the permission list of the assigned group, thus providing more flexible management options.

New default SQL (UserDetailsManager SQL and GroupManager SQL):

	// UserDetailsManager SQL
	public static final String DEF_CREATE_USER_SQL = "insert into users (username, password, enabled) values (?,?,?)";
	public static final String DEF_DELETE_USER_SQL = "delete from users where username = ?";
	public static final String DEF_UPDATE_USER_SQL = "update users set password = ?, enabled = ? where username = ?";
	public static final String DEF_INSERT_AUTHORITY_SQL = "insert into authorities (username, authority) values (?,?)";
	public static final String DEF_DELETE_USER_AUTHORITIES_SQL = "delete from authorities where username = ?";
	public static final String DEF_USER_EXISTS_SQL = "select username from users where username = ?";
	public static final String DEF_CHANGE_PASSWORD_SQL = "update users set password = ? where username = ?";

	// GroupManager SQL
	public static final String DEF_FIND_GROUPS_SQL = "select group_name from groups";
	public static final String DEF_FIND_USERS_IN_GROUP_SQL = "select username from group_members gm, groups g "
			+ "where gm.group_id = g.id and g.group_name = ?";
	public static final String DEF_INSERT_GROUP_SQL = "insert into groups (group_name) values (?)";
	public static final String DEF_FIND_GROUP_ID_SQL = "select id from groups where group_name = ?";
	public static final String DEF_INSERT_GROUP_AUTHORITY_SQL = "insert into group_authorities (group_id, authority) values (?,?)";
	public static final String DEF_DELETE_GROUP_SQL = "delete from groups where id = ?";
	public static final String DEF_DELETE_GROUP_AUTHORITIES_SQL = "delete from group_authorities where group_id = ?";
	public static final String DEF_DELETE_GROUP_MEMBERS_SQL = "delete from group_members where group_id = ?";
	public static final String DEF_RENAME_GROUP_SQL = "update groups set group_name = ? where group_name = ?";
	public static final String DEF_INSERT_GROUP_MEMBER_SQL = "insert into group_members (group_id, username) values (?,?)";
	public static final String DEF_DELETE_GROUP_MEMBER_SQL = "delete from group_members where group_id = ? and username = ?";
	public static final String DEF_GROUP_AUTHORITIES_QUERY_SQL = "select g.id, g.group_name, ga.authority "
			+ "from groups g, group_authorities ga "
			+ "where g.group_name = ? "
			+ "and g.id = ga.group_id ";
	public static final String DEF_DELETE_GROUP_AUTHORITY_SQL = "delete from group_authorities where group_id = ? and authority = ?";

The source code is not pasted. There are too many. The implementation method is similar to that of the JdbcDaoImpl class (by using the default SQL, you can also use the user-defined SQL that meets the requirements to query user related data). You need to use the self-readable source code (or look at the source code more).

CachingUserDetailsService

CachingUserDetailsService class source code (implements the UserDetailsService interface):

public class CachingUserDetailsService implements UserDetailsService {
    // User cache, NullUserCache does not perform any caching
	private UserCache userCache = new NullUserCache();
	// Delegated UserDetailsService instance
	private final UserDetailsService delegate;

	public CachingUserDetailsService(UserDetailsService delegate) {
		this.delegate = delegate;
	}

	public UserCache getUserCache() {
		return userCache;
	}

	public void setUserCache(UserCache userCache) {
		this.userCache = userCache;
	}

	public UserDetails loadUserByUsername(String username) {
	    // Gets the user from the user cache (through the user name), and NullUserCache always returns null
		UserDetails user = userCache.getUserFromCache(username);

		if (user == null) {
		    // Obtain the user (based on user name) through the delegated UserDetailsService instance
			user = delegate.loadUserByUsername(username);
		}

		Assert.notNull(user, () -> "UserDetailsService " + delegate
				+ " returned null for username " + username + ". "
				+ "This is an interface contract violation");
        // Add users to the user cache, and NullUserCache does nothing
		userCache.putUserInCache(user);

		return user;
	}
}

NullUserCache class, does not perform any caching.

public class NullUserCache implements UserCache {

	public UserDetails getUserFromCache(String username) {
		return null;
	}

	public void putUserInCache(UserDetails user) {
	}

	public void removeUserFromCache(String username) {
	}
}

The caching userdetailsservice instance can achieve different caching effects by setting different user caching instances (described later).

    // Set user cache instance
	public void setUserCache(UserCache userCache) {
		this.userCache = userCache;
	}

InMemoryUserDetailsManager

InMemoryUserDetailsManager stores user data through HashMap, which is a non persistent implementation of UserDetailsManager. It is mainly used for testing and demonstration purposes and does not require a complete persistence system.

InMemoryUserDetailsManager class source code (implements the UserDetailsManager and UserDetailsPasswordService interfaces, which define methods for changing UserDetails passwords)

public class InMemoryUserDetailsManager implements UserDetailsManager,
		UserDetailsPasswordService {
	protected final Log logger = LogFactory.getLog(getClass());
    
    // Container for storing user data
	private final Map<String, MutableUserDetails> users = new HashMap<>();
    // It is used to process authentication requests, which will be described in detail later
	private AuthenticationManager authenticationManager;

    // Parameterless constructor
	public InMemoryUserDetailsManager() {
	}

    // Constructor based on user list
	public InMemoryUserDetailsManager(Collection<UserDetails> users) {
		for (UserDetails user : users) {
			createUser(user);
		}
	}
    
	public InMemoryUserDetailsManager(UserDetails... users) {
		for (UserDetails user : users) {
			createUser(user);
		}
	}
	
    // Properties based constructor
	public InMemoryUserDetailsManager(Properties users) {
		Enumeration<?> names = users.propertyNames();
		// UserAttribute editor
		UserAttributeEditor editor = new UserAttributeEditor();

		while (names.hasMoreElements()) {
			String name = (String) names.nextElement();
			editor.setAsText(users.getProperty(name));
			// Used to temporarily store attributes associated with users
			UserAttribute attr = (UserAttribute) editor.getValue();
			// Create UserDetails instance (User instance)
			UserDetails user = new User(name, attr.getPassword(), attr.isEnabled(), true,
					true, true, attr.getAuthorities());
			// Add user to container
			createUser(user);
		}
	}

    // Add user to container
	public void createUser(UserDetails user) {
		Assert.isTrue(!userExists(user.getUsername()), "user should not exist");
        // Convert the UserDetails instance into MutableUser instance and add it to the container
		users.put(user.getUsername().toLowerCase(), new MutableUser(user));
	}

    // Remove user from container
	public void deleteUser(String username) {
		users.remove(username.toLowerCase());
	}

    // Update the specified user in the container
	public void updateUser(UserDetails user) {
		Assert.isTrue(userExists(user.getUsername()), "user should exist");
        // Convert the UserDetails instance to a MutableUser instance for updating the container
		users.put(user.getUsername().toLowerCase(), new MutableUser(user));
	} 
	
    // Determine whether the container has a user with this user name
	public boolean userExists(String username) {
		return users.containsKey(username.toLowerCase());
	}

    // Change Password
	public void changePassword(String oldPassword, String newPassword) {
	    // Obtain the user encapsulation to be authenticated from the SecurityContextHolder, which will be described in detail later
		Authentication currentUser = SecurityContextHolder.getContext()
				.getAuthentication();
        // There are no users to authenticate
		if (currentUser == null) {
			throw new AccessDeniedException(
					"Can't change password as no Authentication object found in context "
							+ "for current user.");
		}
        // User name of the user who needs authentication
		String username = currentUser.getName();

		logger.debug("Changing password for user '" + username + "'");

		// If the AuthenticationManager is set up, re authenticate the user with the password provided
		if (authenticationManager != null) {
			logger.debug("Reauthenticating user '" + username
					+ "' for password change request.");
            // Verify that oldPassword is the user's password
			authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(
					username, oldPassword));
		}
		else {
			logger.debug("No authentication manager set. Password won't be re-checked.");
		}
        
        // Find the user in the container (based on user name)
		MutableUserDetails user = users.get(username);
        
        // The user does not exist in the container
		if (user == null) {
			throw new IllegalStateException("Current user doesn't exist in database.");
		}
        
        // If the above conditions are met, the password can be modified
		user.setPassword(newPassword);
	}

    // Update password
	@Override
	public UserDetails updatePassword(UserDetails user, String newPassword) {
		String username = user.getUsername();
		// Find the user in the container (based on user name)
		MutableUserDetails mutableUser = this.users.get(username.toLowerCase());
		// Set a new password for this user
		mutableUser.setPassword(newPassword);
		return mutableUser;
	}
    
    // Load user (based on user name)
	public UserDetails loadUserByUsername(String username)
			throws UsernameNotFoundException {
		// Find the user in the container (based on user name)
		UserDetails user = users.get(username.toLowerCase());

		if (user == null) {
			throw new UsernameNotFoundException(username);
		}
        // Create a User instance based on the User found in the container
		return new User(user.getUsername(), user.getPassword(), user.isEnabled(),
				user.isAccountNonExpired(), user.isCredentialsNonExpired(),
				user.isAccountNonLocked(), user.getAuthorities());
	}
    
    // Set up the AuthenticationManager to process authentication requests
	public void setAuthenticationManager(AuthenticationManager authenticationManager) {
		this.authenticationManager = authenticationManager;
	}
}

Custom user services

Custom user services:

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // Configure custom user services
        // Configure password encoder (a password encoder that does nothing for testing)
        auth.userDetailsService(new UserDetailsServiceImpl()).passwordEncoder(NoOpPasswordEncoder.getInstance());
    }

    // Custom user services
    public static class UserDetailsServiceImpl implements UserDetailsService {

        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            // Simulate finding users in the database
            // Suppose the USER exists, the password is itkaven, and the role list is USER and ADMIN
            UserDetails userDetails = User.withUsername(username).password("itkaven").roles("USER", "ADMIN").build();
            return userDetails;
        }
    }
}

That's all for the source code analysis of Spring Security's user service UserDetailsService. If the blogger is wrong or you have different opinions, you are welcome to comment and supplement.

Topics: Java Redis Spring Spring Security