在之前的两章,我们使用的用户数据都是用 InMemoryUserDetailsManager 临时保存在内存中,这样的目的是把关注点聚焦在认证和授权方面。在正式的应用系统中,用户的数据需要用某种持久化的方式保存。本章我们讨论如何设计一个具有通用性的创想基类,并且以它为基础完成Spring Security 和关系型数据库的结合。
35.1 设计的任务
为实现 Spring Security 和关系型数据库的结合,我们需要完成如下的工作:
- 完成表结合数据访问类的设计
 - 定义 UserDetails 的实现类
 - 定义 AuthenticationProvider 的实现类
 - 定义 UserDetailsServer 的实现类
 
下面是前两章讨论过的密码验证的流程图,我们要实现自定义的就是黄色背景的三个组件:
35.1 用户和权限数据库及映射类
35.1.1 增加用户数据访问方法
我们在教程第2章设计了一个用户数据表和用注解及映射文件操作它的定义。为完成本章的设计目标,需要给UserMapper类增加更多的方法(当然你也可以选择用在映射文件的定义中增加):
@Select("SELECT * FROM user WHERE user_name = #{user_name}")@Results({@Result(property = "mobile", column = "mobile"),@Result(property = "userName", column = "user_name"),})List<UserEntry> getByName(String userName);@Select("SELECT * FROM user WHERE mobile = #{mobile}")@Results({@Result(property = "mobile", column = "mobile"),@Result(property = "userName", column = "user_name"),})List<UserEntry> getByMobile(String mobile);@Insert("INSERT INTO user(user_name, password) VALUES(#{userName}, #{password})")@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")Integer insertWithPassword(UserEntry userEntry);@Insert("INSERT INTO user(user_name, mobile, password) VALUES(#{userName}, #{mobile}, #{password})")@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")Integer insertWithMobilePassword(UserEntry userEntry);@Update("UPDATE user SET password=#{password} WHERE user_name=#{userName}")void updateByName(String userName, String password);@Delete("DELETE FROM user WHERE user_name=#{userName}")void deleteByname(String userName);@Update("UPDATE user SET password=#{password} WHERE id=#{userId}")void updateById(Long userId, String password);
增加的这些方法都比较简单,他们的用途通过 SQL 语句就可以看出来。
35.1.2 创建用户权限数据库
下面是创建用户权限数据库的 SQL 语句
create table authority (id int unsigned auto_increment comment '主键' primary key,user_id int unsigned not null,authority varchar(50) not null);
35.1.3 设计权限数据管理类
下面是用户权限实体类的类定义:
package com.longser.union.cloud.data.model;import lombok.Data;import javax.validation.constraints.NotBlank;@Datapublic class UserAuthority {private Long id;private Long userId;@NotBlankprivate String authority;public UserAuthority(Long userId, String authority) {this.userId = userId;this.authority = authority;}}
下面是一个用注解方式访问用户权限数据库的类定义(只设计查询和增加两个方法)
package com.longser.union.cloud.data.mapper;import com.longser.union.cloud.data.model.UserAuthority;import org.apache.ibatis.annotations.Insert;import org.apache.ibatis.annotations.Result;import org.apache.ibatis.annotations.Results;import org.apache.ibatis.annotations.Select;import org.springframework.stereotype.Repository;import java.util.List;@Repositorypublic interface UserAuthorityMapper {@Select("SELECT authority FROM authority where user_id=#{userId}")@Results({@Result(property = "authority", column = "authority"),})List<String> getByUserId(Long userId);@Insert("INSERT INTO authority(user_id, authority) VALUES(#{userId}, #{authority})")void insert(UserAuthority userAuthority);}
35.2 UserDetails的实现类
IndexedUser 名字的含义是“有索引值(id)的用户”。如注释所说的,它继承自 security.core.userdetails.User,
扩展的主要内容是增加了用来记录用户在数据库中编号的数据。这样就可以用数据库中的用户编号(而非用户名)去查询权限和修改密码。另外,这个类中还记录了用户的手机号码,可以用于使用手机短信息单次密码(验证码)来认证用户身份。
package com.longser.union.cloud.security;import org.springframework.security.core.GrantedAuthority;import org.springframework.security.core.userdetails.User;import org.springframework.security.core.userdetails.UserDetails;import java.util.Collection;/*** 有索引值(id)的用户类。继承自 security.core.userdetails.User。* 扩展的主要内容是增加了 private Long userId 用来记录用户在数据库中的编号。这样就可以用数据库中的用户编号* (而非用户名)去查询权限和修改密码。* @author David Jia*/public class IndexedUser extends User {private Long userId;private String mobile = "";public IndexedUser(UserDetails user) {super(user.getUsername(), user.getPassword(), user.getAuthorities());}public IndexedUser(String username, String password, Collection<? extends GrantedAuthority> authorities) {super(username, password, authorities);}public IndexedUser(String username, String password, boolean enabled, boolean accountNonExpired,boolean credentialsNonExpired, boolean accountNonLocked,Collection<? extends GrantedAuthority> authorities) {super(username, password, enabled, accountNonExpired, credentialsNonExpired,accountNonLocked,authorities);}public void setUserId(Long userId) {this.userId = userId;}public Long getUserId() {return this.userId;}public void setMobile(String mobile) {this.mobile = mobile;}public String getMobile() {return this.mobile;}}
35.3 Provider的实现类
这个类继承 DaoAuthenticationProvider 之后没有增加新的属性和功能,而是改变了getPasswordEncoder的可用范围以及 createSuccessAuthentication 中封装数据的逻辑(详细见代码注释)。
package com.longser.security.authentication;import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;import org.springframework.security.authentication.dao.DaoAuthenticationProvider;import org.springframework.security.core.Authentication;import org.springframework.security.core.userdetails.UserDetails;import org.springframework.security.crypto.password.PasswordEncoder;/*** 这个类继承 DaoAuthenticationProvider 之后没有增加新的属性和功能,而是改变了两* 个事情(详细见具体方法的注释)* @author David Jia*/public class DaoAuthenticationProviderEx extends DaoAuthenticationProvider {public DaoAuthenticationProviderEx() {super();setHideUserNotFoundExceptions(false);}/*** 这个方法之前是从待认证的 authentication 中取出 UserDetails 对象放到结果中,* 现在我们把从用户仓库中获取的 UserDetails 放到结果中,这样其它地方就能够通过访* 问它来获得当前用户的编号(即 UserId)。* @param principal 这里是用户身份成名,比如用户名* @param authentication 这里是用户输入的待认证的信息* @param user 这是从用户仓库(内存、数据库或 Redis 等)查到的结果* @return 认证后的的 Authentication 对象*/@Overrideprotected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) {UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(principal,authentication.getCredentials(), user.getAuthorities());// 如果想要如 WebAuthenticationDetails 那样保存一些信息,则一方面需要在实// 际的 UserDetail 类中定义合适的属性,还要在这里从 authentication 中取出// 并复制到新 user 中 (注意在做这些之前要判断是否存在期望的数据)。不过类似存// 储 RemoteAddress 或 SessionId 之类的其实没什么实际意义。result.setDetails(user);return result;}/*** 重载这个方法的目的是改变 getPasswordEncoder 原有的可用范围,以便其它类对象可以* 获取到所需 Provider 使用的 PasswordEncoder,这使得更换加密方法的同时能够保持* 相关代码稳定。尽管也可定义全局有效的静态方法返回当前所用 PasswordEncoder,但现* 在这种方法相对更灵活合理一些。总之不要因为更换加密方法而做太多的重构工作。* @return 本 Provider 正在使用的 PasswordEncoder 对象。*/@Overridepublic PasswordEncoder getPasswordEncoder() {return super.getPasswordEncoder();}}
35.4 UserDetailsService 的实现类
在自定义 UserDetailsService 实现类的过程中,我们希望实现一个具有较强扩展性的设计,希望核心的逻辑和代码与具体的数据表结构以及数据存储方式无关、认证的方法(如密码、手机单次密码、邮件验证码等)与验证过程无关。
为实现此目标,我们把自定义的 UserDetailsService 实现类分成两个层次,一层用来管理与永久化存储方式无关核心认证逻辑,一层用来对接具体的数据存储方法。
35.4.1 生成Token的工具接口
这是生成新 Token (Authentication) 的接口。它实现了两个生成 UsernamePasswordAuthenticationToken 的默认方法。实现这个接口的类可以通过重载两个方法来生成其它类型的 Token (Authentication)。
把这两个方法独立放在接口里面会更加方便修改默认值。
如果只是需要使用其它类型的 Token (如OneTimePasswordAuthenticationToken),则不要修改这个接口类,应该是在某个AbstractPersistenceUserDetailsManager的子类中重载本接口的方法。
package com.longser.security.authentication;import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;import org.springframework.security.core.Authentication;import org.springframework.security.core.GrantedAuthority;import java.util.Collection;/*** 生成新 Token (Authentication) 的接口。* 它实现了两个生成 UsernamePasswordAuthenticationToken 的默认方法。实现这个接口的类可以通过重载两个方* 法来生成其它类型的 Token (Authentication)。** 把这两个方法独立放在接口里面会更加方便修改默认值。** 如果只是需要使用其它类型的 Token (如OneTimePasswordAuthenticationToken),则不要修改这个接口类,应* 该是在某个AbstractPersistenceUserDetailsManager的子类中重载本接口的方法。*/public interface TokenFactory {default Authentication getNewToken(Object principal,Collection<? extends GrantedAuthority> authorities,Object details) {UsernamePasswordAuthenticationToken newAuthentication =new UsernamePasswordAuthenticationToken(principal, null, authorities);newAuthentication.setDetails(details);return newAuthentication;}default Authentication getNewToken(Object principal, Object credentials) {return new UsernamePasswordAuthenticationToken(principal, credentials);}}
35.4.2 用持久化用户数据认证的抽象基类
能这是一个够访问持久化保存的用户信息的 UserDetailsService。它是一个抽象基类,必须被继承并在子类中实现全部抽象方法后才能发挥作用。
用户信息可能持久化地保存在关系型数据库或者类似 Redis 的键值数据库等不同的数据源中。数据库结构定义和访问的方法手也随着应用系统的设计而各不相同。Speing Security 中携带的 JDBC 实现只适合做范例和最简单的场景,无法直接用于负责的应用系统。为此这里以尽可能保证开放通用为原则,参考它设计了这个更具通用性的抽象基类。在这个类中既定义了各种必要的方法,但又与实际的持久化方式无关。 所有和具体持久化方式有关的操作被定义成抽象方法,由继承它的非抽象子类定义。
具体使用的时候,你并不需要修改或重载这个类已有的内容,只需要继承它并且实现那几个关键的抽象方法。其方法可以参见下一节对接JDBC数据库的子类。
这个类涉及的方法较多,各方法的解释都在代码注释里。
package com.longser.security.provisioning;import com.longser.security.authentication.TokenFactory;import org.apache.commons.logging.Log;import org.apache.commons.logging.LogFactory;import org.jetbrains.annotations.NotNull;import org.springframework.context.MessageSource;import org.springframework.context.MessageSourceAware;import org.springframework.context.support.MessageSourceAccessor;import org.springframework.core.log.LogMessage;import org.springframework.dao.IncorrectResultSizeDataAccessException;import org.springframework.security.access.AccessDeniedException;import org.springframework.security.authentication.AuthenticationManager;import org.springframework.security.core.Authentication;import org.springframework.security.core.AuthenticationException;import org.springframework.security.core.GrantedAuthority;import org.springframework.security.core.SpringSecurityMessageSource;import org.springframework.security.core.context.SecurityContext;import org.springframework.security.core.context.SecurityContextHolder;import org.springframework.security.core.userdetails.UserCache;import org.springframework.security.core.userdetails.UserDetails;import org.springframework.security.core.userdetails.UserDetailsService;import org.springframework.security.core.userdetails.UsernameNotFoundException;import org.springframework.security.core.userdetails.cache.NullUserCache;import org.springframework.util.Assert;import java.util.ArrayList;import java.util.HashSet;import java.util.List;import java.util.Set;/*** 能够访问持久化保存的用户信息的 UserDetailsService。** 用户信息可能持久化地保存在关系型数据库或者类似 Redis 的键值数据库等不同的数据源中。数据库结构定义和访* 问的方法手也随着应用系统的设计而各不相同。Speing Security 中携带的 JDBC 实现只适合做范例和最简单的* 场景,无法直接用于负责的应用系统。为此这里以尽可能保证开放通用为原则,参考它设计了这个更具通用性的抽象基* 类。在这个类中既定义了各种必要的方法,但又与实际的持久化方式无关。 所有和具体持久化方式有关的操作被定义* 成抽象方法,由继承它的非抽象子类定义。** 具体使用的时候,你并不需要修改或重载这个类已有的内容,只需要继承它并且实现那几个关键的抽象方法。其方法可* 以参见 JdbcUserDetailsService。** @author David Jia*/@SuppressWarnings("unused")public abstract class AbstractPersistenceUserDetailsManagerimplements UserDetailsService, MessageSourceAware, TokenFactory {protected final Log logger = LogFactory.getLog(getClass());private AuthenticationManager authenticationManager;/*** 是否从权限表中加载权限(角色)。默认为 true*/private boolean enableAuthorities = true;/*** 定义角色时使用的前缀。这里认为不需要前缀,所以在保留这个功能的同时设置为空字符串.*/private String rolePrefix = "";/*** 用户缓存。默认不使用缓存。*/private UserCache userCache = new NullUserCache();protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();public AbstractPersistenceUserDetailsManager() {}/*** 这个方法是 UserDetailsService 中定义的唯一的方法,也是 Spring Security 认证过程中最重要的方* 法之一。它从用户的数据源(内存、数据库等)中根据用户名查询用户并返回。这个方法不能返回 null。如果不* 能找到指定的用户,需要抛出异常。** @param username 用户名称* @return 完成验证后的用户信息 UserDetails* @throws UsernameNotFoundException 当没有找到用户时抛出这个异常(而不是返回 null)*/@Overridepublic 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 + "'");// 这里没有直接输出信息而是继续使用预定义的 JdbcDaoImpl.notFound,是为了利// 用Spring Security 现成多语种信息配置(详见教程有关章节)。String exceptionMessage = this.messages.getMessage("JdbcDaoImpl.notFound",new Object[] { username }, "Username {0} not found").replaceAll("JdbcDaoImpl", getClass().getName());throw new UsernameNotFoundException(exceptionMessage);}// 只使用列表中的第一条数据(也不应该有更多的数据)。UserDetails user = users.get(0);// loadUserAuthorities 也是一个抽象的方法。需要在子类中按照用户权限数据的存储// 方法查询指定用户的权限(列表)Set<GrantedAuthority> dbAuthsSet = new HashSet<>();if (this.getEnableAuthorities()) {dbAuthsSet.addAll(loadUserAuthorities(user));}List<GrantedAuthority> dbAuths = new ArrayList<>(dbAuthsSet);addCustomAuthorities(user.getUsername(), dbAuths);if (dbAuths.size() == 0) {// 这里的逻辑继承自 Spring 官方,它要求用户的权限信息不能为空。// 如果你想改变这个原则,建议不要去掉这个判断,可以重载 addCustomAuthorities 方法添加// 一个完全用不到的权限。this.logger.debug("User '" + username + "' has no authorities and will be treated as 'not found'");// 同样的,这里也利用官方现成的提示信息String exceptionMessage = this.messages.getMessage("JdbcDaoImpl.noAuthority",new Object[] { username }, "User {0} has no GrantedAuthority").replaceAll("JdbcDaoImpl", getClass().getName());throw new UsernameNotFoundException(exceptionMessage);}// createUserDetails 也被定义成一个抽象方法,由子类决定返回的 UserDetails 的封装方法(或// 者说封装些什么东西进去)return createUserDetails(user, dbAuths);}/*** 允许子类往权限列表中添加自己额外授予的权限。因为这不是必须做的,所以它不是抽象方法,不是必须重载的。* @param username 用户名* @param authorities 当前的权限列表*/@SuppressWarnings("unused")protected void addCustomAuthorities(String username, List<GrantedAuthority> authorities) {}@SuppressWarnings("unused")public boolean userExists(String username) {// 这里没有做额外的查询,而是判断 loadUsersByUsername 是否有返回结果,这样做可以简化设计。List<UserDetails> users = loadUsersByUsername(username);if (users.size() > 1) {throw new IncorrectResultSizeDataAccessException("More than one user found with name '" + username + "'", 1);}return users.size() == 1;}/*** 简化的情况下,可以在用户修改密码后强制退出(logout)重新登录。如果这样的话,你完全可以在其它的数据* 操作方法中实现这个功能,不需要下面这个复杂的方法。但是如果应用软件选择修改密码后不强制用户退出,那么* 就需要这个复杂的方法,确保既修改数据存储中的用户密码信息,又修改内存中保存的 Authentication 对象* @param oldPassword 当前的密码* @param newPassword 新的密码* @throws AuthenticationException 逻辑要求必须为用户指定权限,否则抛出异常*/public void changePassword(String oldPassword, String newPassword) throws Exception {Authentication currentUser = SecurityContextHolder.getContext().getAuthentication();if (currentUser == null) {// 如果发生这样的事情,说明有代码写错了,调试吧throw new AccessDeniedException("Can't change password as no Authentication object found in context " +"for current user.");}String username = currentUser.getName();// 如果设置了 authentication manager,那么在修改密码之前应该应该校验当前密码是否正确。本来// Spring 做的是可选的设计,但因为认为这是应有的安全过程,所以这里把逻辑修改成在调用本方法前如// 果没有设置 authentication manager 就强制抛出异常。if (this.authenticationManager != null) {this.logger.debug(LogMessage.format("Reauthenticating user '%s' for password change request.", username));this.authenticationManager.authenticate(getNewToken(username, oldPassword));}else {this.logger.debug("No authentication manager set. Password won't be re-checked.");throw new AccessDeniedException("Can't change password as no authentication manager set. " +"Password must be re-checked.");}this.logger.debug("Changing password for user '" + username + "'");// 这是一个抽象的方法,非抽象子类应该完成更新存储中用户密码的实际操作updatePasswordPermanent(currentUser.getDetails(), newPassword);// 这里的逻辑是修改成功存储的数据之后再修改内存中的数据Authentication authentication = createNewAuthentication(currentUser);SecurityContext context = SecurityContextHolder.createEmptyContext();context.setAuthentication(authentication);SecurityContextHolder.setContext(context);this.getUserCache().removeUserFromCache(username);}protected Authentication createNewAuthentication(Authentication currentAuth) {UserDetails user = loadUserByUsername(currentAuth.getName());// getNewToken 默认返回的是一个 UsernamePasswordAuthenticationToken 对象,如果你使用的// 是其它类型的 Authentication,你可以在非抽象子类中重载 getNewToken 方法 (而不是本方法)。return getNewToken(user, user.getAuthorities(), currentAuth.getDetails());}@SuppressWarnings("unused")public void setAuthenticationManager(AuthenticationManager authenticationManager) {this.authenticationManager = authenticationManager;}public AuthenticationManager getAuthenticationManager() {return this.authenticationManager;}public String getRolePrefix() {return this.rolePrefix;}@SuppressWarnings("unused")public void setRolePrefix(String rolePrefix) {this.rolePrefix = rolePrefix;}public boolean getEnableAuthorities() {return this.enableAuthorities;}@SuppressWarnings("unused")public void setEnableAuthorities(boolean enableAuthorities) {this.enableAuthorities = enableAuthorities;}/*** 这是一个可选的设计。如果你在应用系统中使用了 UserCache 那么就在这里设置它。 这允许用户在更新发生后* 从缓存中删除,以避免使用过时的数据。* @param userCache AuthenticationManager 使用的 Cache*/@SuppressWarnings("unused")public void setUserCache(UserCache userCache) {Assert.notNull(userCache, "userCache cannot be null");this.userCache = userCache;}public UserCache getUserCache() {return this.userCache;}@Overridepublic void setMessageSource(@NotNull MessageSource messageSource) {Assert.notNull(messageSource, "messageSource cannot be null");this.messages = new MessageSourceAccessor(messageSource);}/*** 下面都是必须在非抽象子类中重载实现的方法,这些方法都和用户数据实际的存储方式有关。他们的用途在前面的* 注释中都解释过了。** 抽象方法,从存储中根据用户名查找用户信息。* @param username 用户名*/abstract protected List<UserDetails> loadUsersByUsername(String username);/*** 抽象方法,根据用户信息查找用户权限。* @param userDetails loadUsersByUsername 查询到的用户信息,用它进一步查询权限*/abstract protected List<GrantedAuthority> loadUserAuthorities(UserDetails userDetails);/*** 抽象方法根据用户信息和权限信息创建新的用户信息。* @param userFromUserQuery 之前查询到的用户信息* @param combinedAuthorities 之前查询到的用户权限*/abstract protected UserDetails createUserDetails(UserDetails userFromUserQuery,List<GrantedAuthority> combinedAuthorities);/*** 抽象方法,修改存储的用户密码。* @param userDetails 保存在认证对象中的用户信息* @param newPassword 新的密码*/abstract protected void updatePasswordPermanent(Object userDetails, String newPassword);}
35.4.3 对接 JDBC 数据的UserDetailsService实现类
这是一个访问存储在 JDBC 数据源(关系型数据库)中用户和权限信息的 UserDetailsService 实现。它在继承自 AbstractPersistenceUserDetailsManager,使用教程中定义的注解型数据库映射类完成数据库读写。
这个类涉及的方法较多,各方法的解释都在代码注释里。
package com.longser.union.cloud.security;
import com.longser.security.authentication.DaoAuthenticationProviderEx;
import com.longser.security.provisioning.AbstractPersistenceUserDetailsManager;
import com.longser.union.cloud.data.mapper.UserAuthorityMapper;
import com.longser.union.cloud.data.mapper.UserMapper;
import com.longser.union.cloud.data.model.UserAuthority;
import com.longser.union.cloud.data.model.UserEntry;
import org.springframework.security.authentication.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.*;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
 * 这是一个访问存储在 JDBC 数据源(关系型数据库)中用户和权限信息的 UserDetailsService 实现。它在继承
 * 自 AbstractPersistenceUserDetailsManager,使用教程中定义的注解型数据库映射类完成数据库读写。
 * @author David Jia
 */
@Service
public class JdbcUserDetailsService extends AbstractPersistenceUserDetailsManager {
    protected final UserMapper userMapper;
    protected final UserAuthorityMapper userAuthorityMapper;
    public JdbcUserDetailsService(UserMapper userMapper, UserAuthorityMapper userAuthorityMapper) {
        this.userMapper = userMapper;
        this.userAuthorityMapper = userAuthorityMapper;
    }
    /**
     * 根据用户名从数据库中查询用户信息后放到 List 中。通常应该最多只有一条数据(即不应该重名)。
     * @param username 准备查询的用户名
     * @return 保存在 List 中的查询结果
     */
    @Override
    protected List<UserDetails> loadUsersByUsername(String username) {
        List<UserEntry> users = queryUser(username);
        if(users.size() > 0) {
            // 哪怕有多条数据(当然这不应该)也只取第一条
            UserEntry user = users.get(0);
            String userName = getUserName(user);
            String password = user.getPassword();
            // 在我们的教程里没有实现这些属性的设计,所以全部返回 true。如果你在实际应用中实现了相关
            // 的逻辑,那么应该用具体的数据属性来代替
            boolean enabled = true;
            boolean accLocked = false;
            boolean accExpired = false;
            boolean credsExpired = false;
            List<UserDetails> list = new ArrayList<>(1);
            // 在官方实现的 JdbcUserDetailsManager 中创建的是 security.core.userdetails.User
            // 而为了保存用户在的数据库编号(UserId),所以创建的是 IndexedUser
            IndexedUser indexedUser = new IndexedUser(userName, password, enabled, !accExpired, !credsExpired, !accLocked,
                    AuthorityUtils.NO_AUTHORITIES);
            indexedUser.setUserId(user.getId());
            list.add(indexedUser);
            return list;
        }
        // AbstractPersistenceUserDetailsManager 已经设计了找不到用户时的处理逻辑。所以这里如果没
        // 有找到用户就简单返回一个空的 List,其它基类自会处理。
        return new ArrayList<>();
    }
    /**
     * 把实际查询行动放在单独的方法中。初看是不必要的繁复行为,但这是为了如 PhoneUserDetailsService
     * 这样的子类可以直接继承总体的逻辑,只用很少的代码来实现新的目标。
     * @param conditation 查询条件的具体内容。这里时用户名。
     * @return 保存查询结果的 List
     */
    protected List<UserEntry> queryUser(String conditation) {
        return userMapper.getByName(conditation);
    }
    /**
     * 在 user.getUserName() 外面再套一层方法,这样的的原因时为了方便子类重载尽可能少的内容。具体的可
     * 以去看 PhoneUserDetailsService。
     * @param user 从数据库中查询出来的用户信息
     * @return 可作为用户名的字符串,这里时用户名。
     */
    protected String getUserName(UserEntry user) {
        return user.getUserName();
    }
    /**
     * 根据用户信息 userDetails 来查询用户的权限。根据 AbstractPersistenceUserDetailsManager 中
     * 的设计,这里传递过来的 userDetails 的就是在 loadUsersByUsername() 中创建的。所以两个方法中的
     * 实际类型是一样,所以可以大胆地直接所类型指定(转换)。方法实现的逻辑很简单,不需要多解释。
     * @param userDetails 之前 loadUsersByUsername 查询到的用户信息
     * @return 封装了权限信息的 List
     */
    @Override
    protected List<GrantedAuthority> loadUserAuthorities(UserDetails userDetails) {
        List<GrantedAuthority> authorityList = new ArrayList<>();
        List<String> list = userAuthorityMapper.getByUserId(((IndexedUser)userDetails).getUserId());
        for(String authority: list) {
            authorityList.add(new SimpleGrantedAuthority( this.getRolePrefix() + authority));
        }
        // 是不是有权限不需要这里判断处理,直接返回就好
        return authorityList;
    }
    /**
     * 这个方法返回的对象最终被 loadUserByUsername 返回用于登录验证。
     *
     * 因为查询出来的用户信息和用户权限是分别保存的,所以需要这样一个方法来把两者拼起来。这里时生成了一
     * 个新的对象。当然,如果你不喜欢这样,通过可以改造 IndexedUser 类,来把用户权限数据置入一个已经
     * 存在的实例。
     * @param userFromUserQuery loadUsersByUsername 方法返回的查询结果
     * @param combinedAuthorities  全部权限合并在一起的 List 对象
     * @return 被 Spring Security 最终实际使用的 UserDetails
     */
    @Override
    protected UserDetails createUserDetails(UserDetails userFromUserQuery,
                                            List<GrantedAuthority> combinedAuthorities) {
        String returnUsername = userFromUserQuery.getUsername();
        IndexedUser userDetails = new IndexedUser(returnUsername, userFromUserQuery.getPassword(), userFromUserQuery.isEnabled(),
                userFromUserQuery.isAccountNonExpired(), userFromUserQuery.isCredentialsNonExpired(),
                userFromUserQuery.isAccountNonLocked(), combinedAuthorities);
        userDetails.setUserId(((IndexedUser)userFromUserQuery).getUserId());
        return userDetails;
    }
    /**
     * 这个方法把 UserDetails 中的数据插入到数据库中。尽管在应用系统实际的用户管理部分会实现这样的功能,
     * 但因为还是希望保留用 Spring Security 传统风格创建用户的方法,即可以像下面这样
     *              createUser(User.withUsername("david")
     *                 .password(new BCryptPasswordEncoder().encode("123456"))
     *                 .authorities("admin")
     *                 .build());
     * @param user 准备保存的用户数据
     * TODO: 2021/10/1 这里应该把两个步骤做成一个整体的事务才好
     */
    public void createUser(final UserDetails user) {
        UserEntry userEntry = new UserEntry();
        userEntry.setUserName(user.getUsername());
        userEntry.setPassword(user.getPassword());
        if( IndexedUser.class.isAssignableFrom(user.getClass()) ) {
            userEntry.setMobile(((IndexedUser)user).getMobile());
            this.userMapper.insertWithMobilePassword(userEntry);
        } else {
            this.userMapper.insertWithPassword(userEntry);
        }
        Collection<? extends GrantedAuthority> authorities = user.getAuthorities();
        for(GrantedAuthority grantedAuthority: authorities) {
            userAuthorityMapper.insert(new UserAuthority(userEntry.getId(),grantedAuthority.getAuthority()));
        }
    }
    /**
     * 和官方 JdbcUserDetailsManager 中设计的逻辑相比,这里最大的区别在于使用用户编号而不是用户名去
     * 匹配待修改的用户。
     * @param userDetails 从内存中取到的描述当前用户信息的 UserDetails
     * @param newPassword 新的密码
     */
    @Override
    protected void updatePasswordPermanent(Object userDetails, String newPassword) {
        // 要求传递过来参数对象的实例类型必须是 IndexedUser,否则类型转换会失败,也无法得到 UserId,
        // 如果真的不是一个 IndexedUser 实例,那一定代码逻辑出现了错误。
        Assert.isTrue(IndexedUser.class.isAssignableFrom(userDetails.getClass()),
                "The type of user details is " + userDetails.getClass().getName()
                        + " but IndexUser needed.");
        Long userId = ((IndexedUser)userDetails).getUserId();
        ProviderManager providerManager = (ProviderManager)getAuthenticationManager();
        Assert.notNull(providerManager, "You must set AuthenticationManager. Otherwise I couldn't get the PasswordEncoder");
        List<AuthenticationProvider> providers = providerManager.getProviders();
        // 下面通过遍历所有 Provider 找到我们需要的那个,然后调用 getPasswordEncoder 获取它的
        // PasswordEncoder。这个方法使得更换加密方法的同时能够保持相关代码稳定。尽管也可定义全局
        // 有效的静态方法返回当前所用 PasswordEncoder,但现在这种方法相对更灵活合理一些。总之不要
        // 因为更换加密方法而做太多的重构工作。
        // 当然这里也有一个强加的限制,就是对 UsernamePasswordAuthenticationToken 做认证校验
        // 的必须是 DaoAuthenticationProviderEx。
        for(AuthenticationProvider provider : providers) {
            if (provider.supports(UsernamePasswordAuthenticationToken.class)) {
                PasswordEncoder passwordEncoder = ((DaoAuthenticationProviderEx)provider).getPasswordEncoder();
                userMapper.updateById(userId, passwordEncoder.encode(newPassword));
                return;
            }
        }
        throw new AuthenticationServiceException("No respected authentication provider");
    }
}
35.4.4 将手机号码做身份认证的 UserDetailsServer 类
在前一章讨论手机短信息单次密码(验证码)的时候,我们简单地把手机号码保存成了用户名,这在实际的应用系统中是不适合的。通常正式的用户名和用户手机号码是分别存储在不同的字段当中的。
这里定义的在 JdbcUserDetailsService 基础上派生出来的子类,主要用于配合给用户手机发送单次密码(短信息验证码)来做登录验证。为了和用户名密码登录兼容,这个手机号码应该是作为独立的字段保存在数据库用户信息表中的。借助于 JdbcUserDetailsService 看似繁复的设计,这里需要重载的内容极少,并且完全不涉及认证的逻辑细节。
package com.longser.union.cloud.security;
import com.longser.union.cloud.data.mapper.UserAuthorityMapper;
import com.longser.union.cloud.data.mapper.UserMapper;
import com.longser.union.cloud.data.model.UserEntry;
import org.springframework.stereotype.Service;
import java.util.List;
/**
 * 在 JdbcUserDetailsService 基础上派生出来的子类,主要用于配合给用户手机发送单次密码(短信息验证码)
 * 来做登录验证。为了和用户名密码登录兼容,这个手机号码应该是作为独立的字段保存在数据库用户信息表中的。
 *
 * 借助于 JdbcUserDetailsService 看似繁复的设计,这里需要重载的内容极少,并且完全不涉及认证的逻辑细节。
 * @author David Jia
 */
@Service()
public class PhoneUserDetailsService extends JdbcUserDetailsService {
    public PhoneUserDetailsService(UserMapper userMapper, UserAuthorityMapper userAuthorityMapper) {
        super(userMapper, userAuthorityMapper);
    }
    /**
     * 根据手机号码而不是用户名查询用户信息。
     * @param conditation 查询条件的具体内容。这里是手机号码。
     * @return 保存查询结果的 List
     */
    @Override
    protected List<UserEntry> queryUser(String conditation) {
        return this.userMapper.getByMobile(conditation);
    }
    /**
     * 把查询结果中用户的手机号码当做用户名保存待认证对象中
     * @param user 从数据库中查询出来的用户信息
     * @return 用户的手机号码
     */
    @Override
    protected String getUserName(UserEntry user) {
        return user.getMobile();
    }
    /**
     * 修改单次密码认证的密码时没有意义的,因此我们在这里重载并直接抛出异常。防止发生开发错误。
     * @param oldPassword 当前的密码
     * @param newPassword 新的密码
     * @throws Exception 若调用此类实例的 changePassword 方法,直接抛出 IllegalAccessException
     */
    @Override
    public void changePassword(String oldPassword, String newPassword) throws  Exception {
        throw new IllegalAccessException("You shouldn't not call PhoneUserDetailsService.changePassword. " +
                "OTP (One-Time Password) is invalid after authenticated.");
    }
}
35.5 组装与规则配置
本节修改SecurityConfig中的相关配置。
1. 修改注入对象的定义
-   private final InMemoryUserDetailsManager userDetailsService =
+   @Autowired
+   JdbcUserDetailsService jdbcUserDetailsService;
+
+   @Autowired
+   PhoneUserDetailsService phoneUserDetailsService;
2. 关闭 JWT 的设置
在configure(HttpSecurity http) 中
    .and()
-        .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
-   .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
-       http.getConfigurer(OAuth2ResourceServerConfigurer.class)
-               .authenticationEntryPoint(new BearerTokenUnauthorized());
3. 修改 authenticationProvider 的定义
修改 authenticationProvider 的定义为如下的内容
    private DaoAuthenticationProvider authenticationProvider() {
        DaoAuthenticationProviderEx provider = new DaoAuthenticationProviderEx();
        provider.setUserDetailsService(jdbcUserDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }
4. 修改 otpAuthenticationProvider 的定义
修改 otpAuthenticationProvider 的定义为如下的内容
    private OtpAuthenticationProvider otpAuthenticationProvider() {
        OtpAuthenticationProvider provider = new OtpAuthenticationProvider();
        provider.setUserDetailsService(phoneUserDetailsService);
        return provider;
    }
35.6 在接口控制器中实际应用
本节在现有 LoginController 的基础上修改,展示在接口控制器中的实际应用
1. 增加注入的对象
    @Autowired
    JdbcUserDetailsService jdbcUserDetailsService;
2. 不在待认证的 Token 中保存 UserDetails
    @RequestMapping("/login")
    public QueryResult login(HttpServletRequest request,
                             String username, String password) throws MalformedURLException {
        String remoteAddress = request.getRemoteAddr();
        LOGGER.info("[login] {} 正在尝试登录 {} {}", username, remoteAddress , DataTimeUtils.now());
        // 生成一个包含账号密码的认证信息
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
-       AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource = new WebAuthenticationDetailsSource();
-       token.setDetails(authenticationDetailsSource.buildDetails(request));
    @RequestMapping("/smslogin")
    public QueryResult smsLogin(HttpServletRequest request,
                                @RequestParam(value = "phone") String phoneNumber, String password) {
        String remoteAddress = request.getRemoteAddr();
        LOGGER.info("[smslogin] {} 正在尝试登录 {} {}", phoneNumber, remoteAddress , DataTimeUtils.now());
        // 生成一个包含账号密码的认证信息
        OneTimePasswordAuthenticationToken token = new OneTimePasswordAuthenticationToken(phoneNumber, password);
-       AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource = new WebAuthenticationDetailsSource();
-       token.setDetails(authenticationDetailsSource.buildDetails(request));
3. 增加修改密码的 API
    @PostMapping("/changepassword")
    public String changePassword(String oldPassword, String newPassword) throws Exception {
        jdbcUserDetailsService.setAuthenticationManager(authenticationManager);
        jdbcUserDetailsService.changePassword(oldPassword, newPassword);
        return "修改成功";
    }
4. 增加一个创建用户的 API
设计这个 API 有两个目的,一个是展示本章定义的一些方法的能力,另外一个是生成用来测试的数据。
    @Autowired
    PasswordEncoder passwordEncoder;
    @RequestMapping("/createUser")
    public String createUser() {
        jdbcUserDetailsService.createUser(User.withUsername("david")
                //.password(new BCryptPasswordEncoder().encode("123456"))
                .password(passwordEncoder.encode("123456"))
                .authorities("admin", "DBA")
                .build());
        jdbcUserDetailsService.createUser(User.withUsername("admin")
                .password(passwordEncoder.encode("123456"))
                .authorities("admin")
                .build());
        IndexedUser indexedUser = new IndexedUser(User.withUsername("user")
                .password(passwordEncoder.encode("123456"))
                .authorities("user", "read")
                .build());
        indexedUser.setMobile("13808885678");
        jdbcUserDetailsService.createUser(indexedUser);
        return "创建了 3 个用户";
    }
此外,认证成功后不再生成 JWT
-                return JwtUtils.create(username, jwtTimeToLive, bcecPrivateKey, authentication);
+       return "登录成功";
35.7 修改放行逻辑
/api/createUser 这个接口完全是为了开发测试来用的,正式的应用系统中应该是有正式的创建用户的接口和业务流程。所以在放行这个地址的时候我们修改一下之前的代码逻辑,根据当前环境的状态来判断是否放行这个接口。
用下面的代码代替原来的数组定义
    private final String[] publicAPI = {
            "/api/kaptcha.jpg",
            "/api/sendcode",
            "/api/smslogin",
            "/api/login",
            "/api/register",
            "/api/logout",
    };
    private final String[] developmentApi = {
            "/api/createUser",
    };
    private String[] permitedApi;
    @Value("${spring.profiles.active}")
    private String activeProfile;
然后在 configure(HttpSecurity http) 方法的前面根据环境类型决定是否把这两个数组拼装到一起
    protected void configure(HttpSecurity http) throws Exception {
        if(!ApplicationState.PRODUCTION.is(activeProfile)) {
            permitedApi =  ObjectArrays.concat(publicAPI, developmentApi, String.class);
        } else {
            permitedApi =  publicAPI;
        }
35.8 测试
访问 /api/createUser 接口创建用户,应得到如下的结果
{
    "success": true,
    "errorCode": 0,
    "errorMessage": "",
    "data": "创建了 3 个用户"
}
之后的测试这里接不再赘述过程和结果。如果你在测试的过程中没有得到预期结果,那应该是有什么地方搞错了。
版权说明:本文由北京朗思云网科技股份有限公司原创,向互联网开放全部内容但保留所有权力。
