在之前的两章,我们使用的用户数据都是用 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;
@Data
public class UserAuthority {
private Long id;
private Long userId;
@NotBlank
private 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;
@Repository
public 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 对象
*/
@Override
protected 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 对象。
*/
@Override
public 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 AbstractPersistenceUserDetailsManager
implements 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)
*/
@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 + "'");
// 这里没有直接输出信息而是继续使用预定义的 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;
}
@Override
public 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 个用户"
}
之后的测试这里接不再赘述过程和结果。如果你在测试的过程中没有得到预期结果,那应该是有什么地方搞错了。
版权说明:本文由北京朗思云网科技股份有限公司原创,向互联网开放全部内容但保留所有权力。