在之前的两章,我们使用的用户数据都是用 InMemoryUserDetailsManager 临时保存在内存中,这样的目的是把关注点聚焦在认证和授权方面。在正式的应用系统中,用户的数据需要用某种持久化的方式保存。本章我们讨论如何设计一个具有通用性的创想基类,并且以它为基础完成Spring Security 和关系型数据库的结合。

35.1 设计的任务

为实现 Spring Security 和关系型数据库的结合,我们需要完成如下的工作:

  • 完成表结合数据访问类的设计
  • 定义 UserDetails 的实现类
  • 定义 AuthenticationProvider 的实现类
  • 定义 UserDetailsServer 的实现类

下面是前两章讨论过的密码验证的流程图,我们要实现自定义的就是黄色背景的三个组件:
image.png

35.1 用户和权限数据库及映射类

35.1.1 增加用户数据访问方法

我们在教程第2章设计了一个用户数据表和用注解及映射文件操作它的定义。为完成本章的设计目标,需要给UserMapper类增加更多的方法(当然你也可以选择用在映射文件的定义中增加):

  1. @Select("SELECT * FROM user WHERE user_name = #{user_name}")
  2. @Results({
  3. @Result(property = "mobile", column = "mobile"),
  4. @Result(property = "userName", column = "user_name"),
  5. })
  6. List<UserEntry> getByName(String userName);
  7. @Select("SELECT * FROM user WHERE mobile = #{mobile}")
  8. @Results({
  9. @Result(property = "mobile", column = "mobile"),
  10. @Result(property = "userName", column = "user_name"),
  11. })
  12. List<UserEntry> getByMobile(String mobile);
  13. @Insert("INSERT INTO user(user_name, password) VALUES(#{userName}, #{password})")
  14. @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
  15. Integer insertWithPassword(UserEntry userEntry);
  16. @Insert("INSERT INTO user(user_name, mobile, password) VALUES(#{userName}, #{mobile}, #{password})")
  17. @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
  18. Integer insertWithMobilePassword(UserEntry userEntry);
  19. @Update("UPDATE user SET password=#{password} WHERE user_name=#{userName}")
  20. void updateByName(String userName, String password);
  21. @Delete("DELETE FROM user WHERE user_name=#{userName}")
  22. void deleteByname(String userName);
  23. @Update("UPDATE user SET password=#{password} WHERE id=#{userId}")
  24. void updateById(Long userId, String password);

增加的这些方法都比较简单,他们的用途通过 SQL 语句就可以看出来。

35.1.2 创建用户权限数据库

下面是创建用户权限数据库的 SQL 语句

  1. create table authority (
  2. id int unsigned auto_increment comment '主键' primary key,
  3. user_id int unsigned not null,
  4. authority varchar(50) not null
  5. );

35.1.3 设计权限数据管理类

下面是用户权限实体类的类定义:

  1. package com.longser.union.cloud.data.model;
  2. import lombok.Data;
  3. import javax.validation.constraints.NotBlank;
  4. @Data
  5. public class UserAuthority {
  6. private Long id;
  7. private Long userId;
  8. @NotBlank
  9. private String authority;
  10. public UserAuthority(Long userId, String authority) {
  11. this.userId = userId;
  12. this.authority = authority;
  13. }
  14. }

下面是一个用注解方式访问用户权限数据库的类定义(只设计查询和增加两个方法)

  1. package com.longser.union.cloud.data.mapper;
  2. import com.longser.union.cloud.data.model.UserAuthority;
  3. import org.apache.ibatis.annotations.Insert;
  4. import org.apache.ibatis.annotations.Result;
  5. import org.apache.ibatis.annotations.Results;
  6. import org.apache.ibatis.annotations.Select;
  7. import org.springframework.stereotype.Repository;
  8. import java.util.List;
  9. @Repository
  10. public interface UserAuthorityMapper {
  11. @Select("SELECT authority FROM authority where user_id=#{userId}")
  12. @Results({
  13. @Result(property = "authority", column = "authority"),
  14. })
  15. List<String> getByUserId(Long userId);
  16. @Insert("INSERT INTO authority(user_id, authority) VALUES(#{userId}, #{authority})")
  17. void insert(UserAuthority userAuthority);
  18. }

35.2 UserDetails的实现类

IndexedUser 名字的含义是“有索引值(id)的用户”。如注释所说的,它继承自 security.core.userdetails.User,
扩展的主要内容是增加了用来记录用户在数据库中编号的数据。这样就可以用数据库中的用户编号(而非用户名)去查询权限和修改密码。另外,这个类中还记录了用户的手机号码,可以用于使用手机短信息单次密码(验证码)来认证用户身份。

  1. package com.longser.union.cloud.security;
  2. import org.springframework.security.core.GrantedAuthority;
  3. import org.springframework.security.core.userdetails.User;
  4. import org.springframework.security.core.userdetails.UserDetails;
  5. import java.util.Collection;
  6. /**
  7. * 有索引值(id)的用户类。继承自 security.core.userdetails.User。
  8. * 扩展的主要内容是增加了 private Long userId 用来记录用户在数据库中的编号。这样就可以用数据库中的用户编号
  9. * (而非用户名)去查询权限和修改密码。
  10. * @author David Jia
  11. */
  12. public class IndexedUser extends User {
  13. private Long userId;
  14. private String mobile = "";
  15. public IndexedUser(UserDetails user) {
  16. super(user.getUsername(), user.getPassword(), user.getAuthorities());
  17. }
  18. public IndexedUser(String username, String password, Collection<? extends GrantedAuthority> authorities) {
  19. super(username, password, authorities);
  20. }
  21. public IndexedUser(String username, String password, boolean enabled, boolean accountNonExpired,
  22. boolean credentialsNonExpired, boolean accountNonLocked,
  23. Collection<? extends GrantedAuthority> authorities) {
  24. super(username, password, enabled, accountNonExpired, credentialsNonExpired,accountNonLocked,authorities);
  25. }
  26. public void setUserId(Long userId) {
  27. this.userId = userId;
  28. }
  29. public Long getUserId() {
  30. return this.userId;
  31. }
  32. public void setMobile(String mobile) {
  33. this.mobile = mobile;
  34. }
  35. public String getMobile() {
  36. return this.mobile;
  37. }
  38. }

35.3 Provider的实现类

这个类继承 DaoAuthenticationProvider 之后没有增加新的属性和功能,而是改变了getPasswordEncoder的可用范围以及 createSuccessAuthentication 中封装数据的逻辑(详细见代码注释)。

  1. package com.longser.security.authentication;
  2. import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
  3. import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
  4. import org.springframework.security.core.Authentication;
  5. import org.springframework.security.core.userdetails.UserDetails;
  6. import org.springframework.security.crypto.password.PasswordEncoder;
  7. /**
  8. * 这个类继承 DaoAuthenticationProvider 之后没有增加新的属性和功能,而是改变了两
  9. * 个事情(详细见具体方法的注释)
  10. * @author David Jia
  11. */
  12. public class DaoAuthenticationProviderEx extends DaoAuthenticationProvider {
  13. public DaoAuthenticationProviderEx() {
  14. super();
  15. setHideUserNotFoundExceptions(false);
  16. }
  17. /**
  18. * 这个方法之前是从待认证的 authentication 中取出 UserDetails 对象放到结果中,
  19. * 现在我们把从用户仓库中获取的 UserDetails 放到结果中,这样其它地方就能够通过访
  20. * 问它来获得当前用户的编号(即 UserId)。
  21. * @param principal 这里是用户身份成名,比如用户名
  22. * @param authentication 这里是用户输入的待认证的信息
  23. * @param user 这是从用户仓库(内存、数据库或 Redis 等)查到的结果
  24. * @return 认证后的的 Authentication 对象
  25. */
  26. @Override
  27. protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) {
  28. UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(principal,
  29. authentication.getCredentials(), user.getAuthorities());
  30. // 如果想要如 WebAuthenticationDetails 那样保存一些信息,则一方面需要在实
  31. // 际的 UserDetail 类中定义合适的属性,还要在这里从 authentication 中取出
  32. // 并复制到新 user 中 (注意在做这些之前要判断是否存在期望的数据)。不过类似存
  33. // 储 RemoteAddress 或 SessionId 之类的其实没什么实际意义。
  34. result.setDetails(user);
  35. return result;
  36. }
  37. /**
  38. * 重载这个方法的目的是改变 getPasswordEncoder 原有的可用范围,以便其它类对象可以
  39. * 获取到所需 Provider 使用的 PasswordEncoder,这使得更换加密方法的同时能够保持
  40. * 相关代码稳定。尽管也可定义全局有效的静态方法返回当前所用 PasswordEncoder,但现
  41. * 在这种方法相对更灵活合理一些。总之不要因为更换加密方法而做太多的重构工作。
  42. * @return 本 Provider 正在使用的 PasswordEncoder 对象。
  43. */
  44. @Override
  45. public PasswordEncoder getPasswordEncoder() {
  46. return super.getPasswordEncoder();
  47. }
  48. }

35.4 UserDetailsService 的实现类

在自定义 UserDetailsService 实现类的过程中,我们希望实现一个具有较强扩展性的设计,希望核心的逻辑和代码与具体的数据表结构以及数据存储方式无关、认证的方法(如密码、手机单次密码、邮件验证码等)与验证过程无关。

为实现此目标,我们把自定义的 UserDetailsService 实现类分成两个层次,一层用来管理与永久化存储方式无关核心认证逻辑,一层用来对接具体的数据存储方法。

35.4.1 生成Token的工具接口

这是生成新 Token (Authentication) 的接口。它实现了两个生成 UsernamePasswordAuthenticationToken 的默认方法。实现这个接口的类可以通过重载两个方法来生成其它类型的 Token (Authentication)。

把这两个方法独立放在接口里面会更加方便修改默认值。

如果只是需要使用其它类型的 Token (如OneTimePasswordAuthenticationToken),则不要修改这个接口类,应该是在某个AbstractPersistenceUserDetailsManager的子类中重载本接口的方法。

  1. package com.longser.security.authentication;
  2. import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
  3. import org.springframework.security.core.Authentication;
  4. import org.springframework.security.core.GrantedAuthority;
  5. import java.util.Collection;
  6. /**
  7. * 生成新 Token (Authentication) 的接口。
  8. * 它实现了两个生成 UsernamePasswordAuthenticationToken 的默认方法。实现这个接口的类可以通过重载两个方
  9. * 法来生成其它类型的 Token (Authentication)。
  10. *
  11. * 把这两个方法独立放在接口里面会更加方便修改默认值。
  12. *
  13. * 如果只是需要使用其它类型的 Token (如OneTimePasswordAuthenticationToken),则不要修改这个接口类,应
  14. * 该是在某个AbstractPersistenceUserDetailsManager的子类中重载本接口的方法。
  15. */
  16. public interface TokenFactory {
  17. default Authentication getNewToken(Object principal,
  18. Collection<? extends GrantedAuthority> authorities,
  19. Object details) {
  20. UsernamePasswordAuthenticationToken newAuthentication =
  21. new UsernamePasswordAuthenticationToken(principal, null, authorities);
  22. newAuthentication.setDetails(details);
  23. return newAuthentication;
  24. }
  25. default Authentication getNewToken(Object principal, Object credentials) {
  26. return new UsernamePasswordAuthenticationToken(principal, credentials);
  27. }
  28. }

35.4.2 用持久化用户数据认证的抽象基类

能这是一个够访问持久化保存的用户信息的 UserDetailsService。它是一个抽象基类,必须被继承并在子类中实现全部抽象方法后才能发挥作用。

用户信息可能持久化地保存在关系型数据库或者类似 Redis 的键值数据库等不同的数据源中。数据库结构定义和访问的方法手也随着应用系统的设计而各不相同。Speing Security 中携带的 JDBC 实现只适合做范例和最简单的场景,无法直接用于负责的应用系统。为此这里以尽可能保证开放通用为原则,参考它设计了这个更具通用性的抽象基类。在这个类中既定义了各种必要的方法,但又与实际的持久化方式无关。 所有和具体持久化方式有关的操作被定义成抽象方法,由继承它的非抽象子类定义。

具体使用的时候,你并不需要修改或重载这个类已有的内容,只需要继承它并且实现那几个关键的抽象方法。其方法可以参见下一节对接JDBC数据库的子类。

这个类涉及的方法较多,各方法的解释都在代码注释里。

  1. package com.longser.security.provisioning;
  2. import com.longser.security.authentication.TokenFactory;
  3. import org.apache.commons.logging.Log;
  4. import org.apache.commons.logging.LogFactory;
  5. import org.jetbrains.annotations.NotNull;
  6. import org.springframework.context.MessageSource;
  7. import org.springframework.context.MessageSourceAware;
  8. import org.springframework.context.support.MessageSourceAccessor;
  9. import org.springframework.core.log.LogMessage;
  10. import org.springframework.dao.IncorrectResultSizeDataAccessException;
  11. import org.springframework.security.access.AccessDeniedException;
  12. import org.springframework.security.authentication.AuthenticationManager;
  13. import org.springframework.security.core.Authentication;
  14. import org.springframework.security.core.AuthenticationException;
  15. import org.springframework.security.core.GrantedAuthority;
  16. import org.springframework.security.core.SpringSecurityMessageSource;
  17. import org.springframework.security.core.context.SecurityContext;
  18. import org.springframework.security.core.context.SecurityContextHolder;
  19. import org.springframework.security.core.userdetails.UserCache;
  20. import org.springframework.security.core.userdetails.UserDetails;
  21. import org.springframework.security.core.userdetails.UserDetailsService;
  22. import org.springframework.security.core.userdetails.UsernameNotFoundException;
  23. import org.springframework.security.core.userdetails.cache.NullUserCache;
  24. import org.springframework.util.Assert;
  25. import java.util.ArrayList;
  26. import java.util.HashSet;
  27. import java.util.List;
  28. import java.util.Set;
  29. /**
  30. * 能够访问持久化保存的用户信息的 UserDetailsService。
  31. *
  32. * 用户信息可能持久化地保存在关系型数据库或者类似 Redis 的键值数据库等不同的数据源中。数据库结构定义和访
  33. * 问的方法手也随着应用系统的设计而各不相同。Speing Security 中携带的 JDBC 实现只适合做范例和最简单的
  34. * 场景,无法直接用于负责的应用系统。为此这里以尽可能保证开放通用为原则,参考它设计了这个更具通用性的抽象基
  35. * 类。在这个类中既定义了各种必要的方法,但又与实际的持久化方式无关。 所有和具体持久化方式有关的操作被定义
  36. * 成抽象方法,由继承它的非抽象子类定义。
  37. *
  38. * 具体使用的时候,你并不需要修改或重载这个类已有的内容,只需要继承它并且实现那几个关键的抽象方法。其方法可
  39. * 以参见 JdbcUserDetailsService。
  40. *
  41. * @author David Jia
  42. */
  43. @SuppressWarnings("unused")
  44. public abstract class AbstractPersistenceUserDetailsManager
  45. implements UserDetailsService, MessageSourceAware, TokenFactory {
  46. protected final Log logger = LogFactory.getLog(getClass());
  47. private AuthenticationManager authenticationManager;
  48. /**
  49. * 是否从权限表中加载权限(角色)。默认为 true
  50. */
  51. private boolean enableAuthorities = true;
  52. /**
  53. * 定义角色时使用的前缀。这里认为不需要前缀,所以在保留这个功能的同时设置为空字符串.
  54. */
  55. private String rolePrefix = "";
  56. /**
  57. * 用户缓存。默认不使用缓存。
  58. */
  59. private UserCache userCache = new NullUserCache();
  60. protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
  61. public AbstractPersistenceUserDetailsManager() {
  62. }
  63. /**
  64. * 这个方法是 UserDetailsService 中定义的唯一的方法,也是 Spring Security 认证过程中最重要的方
  65. * 法之一。它从用户的数据源(内存、数据库等)中根据用户名查询用户并返回。这个方法不能返回 null。如果不
  66. * 能找到指定的用户,需要抛出异常。
  67. *
  68. * @param username 用户名称
  69. * @return 完成验证后的用户信息 UserDetails
  70. * @throws UsernameNotFoundException 当没有找到用户时抛出这个异常(而不是返回 null)
  71. */
  72. @Override
  73. public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
  74. // 调用一个抽象方法,根据用户名获取用户。这个抽象方法需要在子类中按照用户数据的存储方式查询。因为
  75. // 可能会有多个结果,所以放在列表对象中。
  76. List<UserDetails> users = loadUsersByUsername(username);
  77. if (users.size() == 0) {
  78. this.logger.debug("Query returned no results for user '" + username + "'");
  79. // 这里没有直接输出信息而是继续使用预定义的 JdbcDaoImpl.notFound,是为了利
  80. // 用Spring Security 现成多语种信息配置(详见教程有关章节)。
  81. String exceptionMessage = this.messages.getMessage("JdbcDaoImpl.notFound",
  82. new Object[] { username }, "Username {0} not found")
  83. .replaceAll("JdbcDaoImpl", getClass().getName());
  84. throw new UsernameNotFoundException(exceptionMessage);
  85. }
  86. // 只使用列表中的第一条数据(也不应该有更多的数据)。
  87. UserDetails user = users.get(0);
  88. // loadUserAuthorities 也是一个抽象的方法。需要在子类中按照用户权限数据的存储
  89. // 方法查询指定用户的权限(列表)
  90. Set<GrantedAuthority> dbAuthsSet = new HashSet<>();
  91. if (this.getEnableAuthorities()) {
  92. dbAuthsSet.addAll(loadUserAuthorities(user));
  93. }
  94. List<GrantedAuthority> dbAuths = new ArrayList<>(dbAuthsSet);
  95. addCustomAuthorities(user.getUsername(), dbAuths);
  96. if (dbAuths.size() == 0) {
  97. // 这里的逻辑继承自 Spring 官方,它要求用户的权限信息不能为空。
  98. // 如果你想改变这个原则,建议不要去掉这个判断,可以重载 addCustomAuthorities 方法添加
  99. // 一个完全用不到的权限。
  100. this.logger.debug("User '" + username + "' has no authorities and will be treated as 'not found'");
  101. // 同样的,这里也利用官方现成的提示信息
  102. String exceptionMessage = this.messages.getMessage("JdbcDaoImpl.noAuthority",
  103. new Object[] { username }, "User {0} has no GrantedAuthority")
  104. .replaceAll("JdbcDaoImpl", getClass().getName());
  105. throw new UsernameNotFoundException(exceptionMessage);
  106. }
  107. // createUserDetails 也被定义成一个抽象方法,由子类决定返回的 UserDetails 的封装方法(或
  108. // 者说封装些什么东西进去)
  109. return createUserDetails(user, dbAuths);
  110. }
  111. /**
  112. * 允许子类往权限列表中添加自己额外授予的权限。因为这不是必须做的,所以它不是抽象方法,不是必须重载的。
  113. * @param username 用户名
  114. * @param authorities 当前的权限列表
  115. */
  116. @SuppressWarnings("unused")
  117. protected void addCustomAuthorities(String username, List<GrantedAuthority> authorities) {
  118. }
  119. @SuppressWarnings("unused")
  120. public boolean userExists(String username) {
  121. // 这里没有做额外的查询,而是判断 loadUsersByUsername 是否有返回结果,这样做可以简化设计。
  122. List<UserDetails> users = loadUsersByUsername(username);
  123. if (users.size() > 1) {
  124. throw new IncorrectResultSizeDataAccessException(
  125. "More than one user found with name '" + username + "'", 1);
  126. }
  127. return users.size() == 1;
  128. }
  129. /**
  130. * 简化的情况下,可以在用户修改密码后强制退出(logout)重新登录。如果这样的话,你完全可以在其它的数据
  131. * 操作方法中实现这个功能,不需要下面这个复杂的方法。但是如果应用软件选择修改密码后不强制用户退出,那么
  132. * 就需要这个复杂的方法,确保既修改数据存储中的用户密码信息,又修改内存中保存的 Authentication 对象
  133. * @param oldPassword 当前的密码
  134. * @param newPassword 新的密码
  135. * @throws AuthenticationException 逻辑要求必须为用户指定权限,否则抛出异常
  136. */
  137. public void changePassword(String oldPassword, String newPassword) throws Exception {
  138. Authentication currentUser = SecurityContextHolder.getContext().getAuthentication();
  139. if (currentUser == null) {
  140. // 如果发生这样的事情,说明有代码写错了,调试吧
  141. throw new AccessDeniedException(
  142. "Can't change password as no Authentication object found in context " +
  143. "for current user.");
  144. }
  145. String username = currentUser.getName();
  146. // 如果设置了 authentication manager,那么在修改密码之前应该应该校验当前密码是否正确。本来
  147. // Spring 做的是可选的设计,但因为认为这是应有的安全过程,所以这里把逻辑修改成在调用本方法前如
  148. // 果没有设置 authentication manager 就强制抛出异常。
  149. if (this.authenticationManager != null) {
  150. this.logger.debug(LogMessage.format("Reauthenticating user '%s' for password change request.", username));
  151. this.authenticationManager.authenticate(getNewToken(username, oldPassword));
  152. }
  153. else {
  154. this.logger.debug("No authentication manager set. Password won't be re-checked.");
  155. throw new AccessDeniedException(
  156. "Can't change password as no authentication manager set. " +
  157. "Password must be re-checked.");
  158. }
  159. this.logger.debug("Changing password for user '" + username + "'");
  160. // 这是一个抽象的方法,非抽象子类应该完成更新存储中用户密码的实际操作
  161. updatePasswordPermanent(currentUser.getDetails(), newPassword);
  162. // 这里的逻辑是修改成功存储的数据之后再修改内存中的数据
  163. Authentication authentication = createNewAuthentication(currentUser);
  164. SecurityContext context = SecurityContextHolder.createEmptyContext();
  165. context.setAuthentication(authentication);
  166. SecurityContextHolder.setContext(context);
  167. this.getUserCache().removeUserFromCache(username);
  168. }
  169. protected Authentication createNewAuthentication(Authentication currentAuth) {
  170. UserDetails user = loadUserByUsername(currentAuth.getName());
  171. // getNewToken 默认返回的是一个 UsernamePasswordAuthenticationToken 对象,如果你使用的
  172. // 是其它类型的 Authentication,你可以在非抽象子类中重载 getNewToken 方法 (而不是本方法)。
  173. return getNewToken(user, user.getAuthorities(), currentAuth.getDetails());
  174. }
  175. @SuppressWarnings("unused")
  176. public void setAuthenticationManager(AuthenticationManager authenticationManager) {
  177. this.authenticationManager = authenticationManager;
  178. }
  179. public AuthenticationManager getAuthenticationManager() {
  180. return this.authenticationManager;
  181. }
  182. public String getRolePrefix() {
  183. return this.rolePrefix;
  184. }
  185. @SuppressWarnings("unused")
  186. public void setRolePrefix(String rolePrefix) {
  187. this.rolePrefix = rolePrefix;
  188. }
  189. public boolean getEnableAuthorities() {
  190. return this.enableAuthorities;
  191. }
  192. @SuppressWarnings("unused")
  193. public void setEnableAuthorities(boolean enableAuthorities) {
  194. this.enableAuthorities = enableAuthorities;
  195. }
  196. /**
  197. * 这是一个可选的设计。如果你在应用系统中使用了 UserCache 那么就在这里设置它。 这允许用户在更新发生后
  198. * 从缓存中删除,以避免使用过时的数据。
  199. * @param userCache AuthenticationManager 使用的 Cache
  200. */
  201. @SuppressWarnings("unused")
  202. public void setUserCache(UserCache userCache) {
  203. Assert.notNull(userCache, "userCache cannot be null");
  204. this.userCache = userCache;
  205. }
  206. public UserCache getUserCache() {
  207. return this.userCache;
  208. }
  209. @Override
  210. public void setMessageSource(@NotNull MessageSource messageSource) {
  211. Assert.notNull(messageSource, "messageSource cannot be null");
  212. this.messages = new MessageSourceAccessor(messageSource);
  213. }
  214. /**
  215. * 下面都是必须在非抽象子类中重载实现的方法,这些方法都和用户数据实际的存储方式有关。他们的用途在前面的
  216. * 注释中都解释过了。
  217. *
  218. * 抽象方法,从存储中根据用户名查找用户信息。
  219. * @param username 用户名
  220. */
  221. abstract protected List<UserDetails> loadUsersByUsername(String username);
  222. /**
  223. * 抽象方法,根据用户信息查找用户权限。
  224. * @param userDetails loadUsersByUsername 查询到的用户信息,用它进一步查询权限
  225. */
  226. abstract protected List<GrantedAuthority> loadUserAuthorities(UserDetails userDetails);
  227. /**
  228. * 抽象方法根据用户信息和权限信息创建新的用户信息。
  229. * @param userFromUserQuery 之前查询到的用户信息
  230. * @param combinedAuthorities 之前查询到的用户权限
  231. */
  232. abstract protected UserDetails createUserDetails(UserDetails userFromUserQuery,
  233. List<GrantedAuthority> combinedAuthorities);
  234. /**
  235. * 抽象方法,修改存储的用户密码。
  236. * @param userDetails 保存在认证对象中的用户信息
  237. * @param newPassword 新的密码
  238. */
  239. abstract protected void updatePasswordPermanent(Object userDetails, String newPassword);
  240. }

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 个用户"
}

之后的测试这里接不再赘述过程和结果。如果你在测试的过程中没有得到预期结果,那应该是有什么地方搞错了。

版权说明:本文由北京朗思云网科技股份有限公司原创,向互联网开放全部内容但保留所有权力。