SpringSecurity Oauth2 -账号密码实现多因子身份认证
1. 密码策略问题
CREATE TABLE `t_storage` (`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键',`nameSpace` varchar(64) NOT NULL COMMENT '隔离字段',`groupId` varchar(128) NOT NULL COMMENT '分组,比如不同app',`dataId` varchar(64) NOT NULL COMMENT '数据存储id',`dataValue` mediumtext DEFAULT NULL,PRIMARY KEY (`id`) USING BTREE,UNIQUE KEY `nameSpace` (`nameSpace`,`groupId`,`dataId`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC;
INSERT INTO `t_storage`(`id`, `nameSpace`, `groupId`, `dataId`, `dataValue`) VALUES (1352, '_system', 'auth', 'access_token_validity_seconds', '43200');INSERT INTO `t_storage`(`id`, `nameSpace`, `groupId`, `dataId`, `dataValue`) VALUES (1353, '_system', 'auth', 'refresh_token_validity_seconds', '86400');INSERT INTO `t_storage`(`id`, `nameSpace`, `groupId`, `dataId`, `dataValue`) VALUES (36, '_system', 'console', 'consoleConfig', '{\r\n \"overTime\": 360,\r\n \"stageName\": \"安全大数据平台\",\r\n \"icmpEnable\": true,\r\n \"sshEnable\": false\r\n }');INSERT INTO `t_storage`(`id`, `nameSpace`, `groupId`, `dataId`, `dataValue`) VALUES (45, '_system', 'auth', 'policy', '{\"passwordPeriod\":90,\"errorTimes\":5,\"lockTime\":5,\"minLength\":8}');
1. JsonStorage 封装处理Storage操作
@Slf4j
public class JsonStorage {private IStorage storage;@Autowiredprivate void setStorage(IStorage storage) {this.storage = storage;}private final ObjectMapper JSON_MAPPER = new ObjectMapper();/*** 通过实体对象设置Json字符串** @param nameSpace 命名空间* @param groupId 分组* @param key 键* @param entity 实体对象* @return* @throws JsonProcessingException*/public boolean setJsonDataWithEntity(String nameSpace, String groupId, String key, Object entity) {try {String jsonString = JSON_MAPPER.writeValueAsString(entity);storage.setDataValue(nameSpace, groupId, key, jsonString);return true;} catch (JsonProcessingException | StorageException e) {log.error("Storage setValue failed, error: {}", e);return false;}}/*** 获取由JsonString转化后的实体对象** @param nameSpace 命名空间* @param groupId 分组* @param key 键* @param classType 实体类型* @param <T> 泛型* @return 通过传入的类型返回对应的实体对象* @throws StorageException*/public <T> T getJsonDataAsEntity(String nameSpace, String groupId, String key, Class<T> classType) throws StorageException {try {String jsonString = storage.getDataValue(nameSpace, groupId, key);if (!StringUtils.isEmpty(jsonString)) {JSON_MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);return JSON_MAPPER.readValue(jsonString, classType);} else {log.error("Storage get value failed, value is empty");throw new StorageException("storage.value.empty");}} catch (JsonProcessingException e) {log.error("Storage get value failed, jsonString format error", e);throw new StorageException("storage.getValue.format.error");}}
}
2. StorageDb 数据库数据查询和更新
@Slf4j
public class StorageDb implements IStorage {@Autowiredprivate RedisUtils redisUtils;@Autowired@Qualifier("storageJdbcTemplate")private JdbcTemplate jdbcTemplate;@Autowiredprivate StorageConfig storageConfig;@Override@CheckParampublic void setDataValue(@CheckParam(length = 48) String nameSpace,@CheckParam(length = 96) String groupId,@CheckParam(length = 96) String dataId,@CheckParam(length = 10000, isCheckLegal = false, required = false) String value) throws StorageException {// 同一nameSpace, groupId, dataId下数据不存在则新增,否则更新String updateSql = String.format("insert into %s(nameSpace,groupId,dataId,dataValue) values (?,?,?,?) "+ " on duplicate key update dataValue=values(dataValue)",StorageConstants.TABLE_NAME);int rows = jdbcTemplate.update(updateSql, nameSpace, groupId, dataId, value);if (rows > 0) {// 更新完数据将Redis缓存删除String keyName = generateKey(nameSpace, groupId, dataId);redisUtils.delete(keyName);}}@Override@CheckParampublic String getDataValue(@CheckParam(length = 48) String nameSpace,@CheckParam(length = 96) String groupId,@CheckParam(length = 96) String dataId) {// 拼接redis缓存keyString keyName = generateKey(nameSpace, groupId, dataId);// 先从redis进行获取数据String dataValue = redisUtils.getString(keyName);// 缓存不存在则查询数据库if (Objects.isNull(dataValue)) {String sql = String.format("select dataValue from %s where nameSpace=? and groupId=? and dataId=?", StorageConstants.TABLE_NAME);try {dataValue = jdbcTemplate.queryForObject(sql, String.class, nameSpace, groupId, dataId);} catch (DataAccessException e) {log.error("Failed to query storage, nameSpace = {}, groupId = {}, dataId = {}, error {}", nameSpace, groupId, dataId, e);}// 查询完数据将其写入redis,空值也缓存,防止缓存穿透redisUtils.setWithExpired(keyName, StringUtils.isEmpty(dataValue) ? "" : dataValue, storageConfig.getExpiredTime(), TimeUnit.SECONDS);}return dataValue;}/*** 生成rediskey** @param fields* @return*/private String generateKey(String... fields) {return StorageConstants.REDIS_KEY_PREFIX.concat(String.join(StorageConstants.SEPARATOR, fields));}
}
缓存查询优先:在 getDataValue
方法中,先查询 Redis 缓存,如果没有命中缓存,再查询数据库。这样有效减少了数据库查询次数,提高了性能。
缓存穿透防护:通过将空结果也缓存起来,避免恶意或频繁访问不存在的数据造成缓存穿透,进而影响数据库的负载。
缓存一致性:在 setDataValue
方法中,在更新或插入数据库后,会清除 Redis 缓存中的旧值,确保缓存与数据库的一致性。
3. 获取密码策略
1. PasswordPolicyEntity 密码策略
@ApiModel
@Data
@JsonInclude(value = JsonInclude.Include.NON_NULL)
public class PasswordPolicyEntity {@JsonInclude(value = JsonInclude.Include.USE_DEFAULTS)@ApiModelProperty(value = "密码周期", required = true)private Integer passwordPeriod = 90;@JsonInclude(value = JsonInclude.Include.USE_DEFAULTS)@ApiModelProperty(value = "登录密码输错次数限制", required = true)private Integer errorTimes = 5;@JsonInclude(value = JsonInclude.Include.USE_DEFAULTS)@ApiModelProperty(value = "密码输入错误达到限制后锁定锁定时间", required = true)private Integer lockTime = 5;@JsonInclude(value = JsonInclude.Include.USE_DEFAULTS)@ApiModelProperty(value = "密码最小长度", required = true)private Integer minLength = 8;}
密码策略升级通过flyway初始化数据的:
INSERT INTO `t_storage`(`id`, `nameSpace`, `groupId`, `dataId`, `dataValue`) VALUES (45, '_system', 'auth', 'policy', '{\"passwordPeriod\":90,\"errorTimes\":5,\"lockTime\":5,\"minLength\":8}');
2. 获取密码策略并判断客户端IP是否被锁定
读取数据库中的数据并dataValue字段转换为PasswordPolicyEntity对象
@Slf4j
@Service("loginService")
public class LoginServiceImpl implements LoginService {@Overridepublic LoginResult checkLogin(LoginEntity loginEntity, HttpServletRequest request) {LoginResult loginResult = new LoginResult();// 获取客户端真实IPString clientIp = ClientUtils.getClientIp(request);// 读取密码策略PasswordPolicyEntity policyEntity = userService.readPasswordPolicy();// 判断客户端IP是否被锁定if (isLocked(clientIp)) {loginResult.setStatus(I18nUtils.i18n("response.login.lock.time", policyEntity.getErrorTimes(), policyEntity.getLockTime()));return loginResult;}}
}
获取密码策略:
@Slf4j
@Service("userService")
public class UserServiceImpl implements IUserService {private final ReentrantReadWriteLock.ReadLock readLock = new ReentrantReadWriteLock().readLock();@Overridepublic PasswordPolicyEntity readPasswordPolicy() {PasswordPolicyEntity passwordPolicyEntity = null;readLock.lock();try {passwordPolicyEntity = jsonStorage.getJsonDataAsEntity(SettingNameSpaceConstant.Config.SYSTEM_CONFIG, AuthLoginConstant.AUTH_DIRECTORY, AuthLoginConstant.POLICY, PasswordPolicyEntity.class);} catch (StorageException e) {log.error("Error read pwd policy", e);} finally {readLock.unlock();}return passwordPolicyEntity;}
}
判断客户端IP是否被锁定,密码输入错误次数达到5次后就会被锁定,锁定时间为5分钟:
@Override
public boolean isLocked(String ipAddress) {HashOperations<String, String, String> hashOperations = redisTemplate.opsForHash();String authIpAddressKey = RedisKeyUtil.getAuthIpAddressKey(ipAddress);String next = hashOperations.get(authIpAddressKey, Constants.NgsocAuth.NEXT);if (next != null) {Integer lockTime = userService.readPasswordPolicy().getLockTime();// 当前时间(分钟)-锁定时间<=5分钟,说明锁定了double differentTime = (LocalDateUtils.getNowMilliseconds() - Long.parseLong(next)) / (1000 * 60 * 1.0);return (int) differentTime <= lockTime;}return false;
}
判断某个 IP 地址是否被锁定。具体来说,它通过从 Redis 中获取该 IP 地址相关的锁定时间(next
),并根据当前时间与锁定时间的差值来判断是否仍处于锁定状态。如果锁定时间未超过指定的时长(lockTime
),则该 IP 地址被视为仍然锁定。
2. 多因子认证登录
1. 登录实体类 LoginEntity2. 认证方式枚举 AuthMethodEnum
@Getter
@NoArgsConstructor
@AllArgsConstructor
public enum AuthMethodEnum {/*** 密码校验*/PASSWORD("password", "密码校验"),/*** 手机短信验证码*/SMS("sms", "手机短信验证码"),;private String code;private String desc;public static AuthMethodEnum getByCode(String code) {for (AuthMethodEnum authMethodEnum : values()) {if (authMethodEnum.getCode().equals(code)) {return authMethodEnum;}}return null;}
}
3. 身份认证接口 AuthMethod
public interface AuthMethod {/*** 进行认证** @param loginEntity 参数* @return String 用户ID*/String doAuthenticate(LoginEntity loginEntity);
}
4. 密码认证方式 PasswordAuthMethod
利用责任链模式实现账号密码的多因子身份认证机制,在密码认证时利用Redis Hash结构处理登录失败的次数记录和客户端锁定逻辑。
对用户账号和密码进行校验,并在校验通过后生成并返回用户的身份标识 (userId
)。同时,它还处理了登录失败的次数记录和账户锁定逻辑。
定义了一个名为 PasswordAuthMethod
的类,该类实现了 AuthMethod
接口,用于处理用户通过密码进行身份认证的逻辑。它使用了 Redis 来管理登录失败次数和客户端 IP 的锁定机制。
密码认证逻辑 (doAuthenticate
):
- 获取用户输入的用户名、域 ID、原始密码和客户端 IP 地址。
- 调用
userService.findUserByNameAndDomainId
获取用户信息,如果用户不存在,记录登录失败次数并返回。 - 通过
BcryptUtil.bEncryptMatch
比较明文密码和数据库中加密的密码,判断密码是否正确。如果密码不正确,记录失败次数并返回。 - 如果密码正确且未超过登录失败次数限制,删除与该 IP 相关的锁定信息,允许登录。
记录登录失败次数 (recordLoginAttempts
):
- 通过 Redis 记录 IP 地址对应的登录失败次数,使用
increment
方法将登录失败次数加 1。 - 根据从
userService.readPasswordPolicy()
获取的密码策略,设置失败次数锁定的过期时间和锁定阈值。 - 当登录失败次数超过或等于设定的错误次数时,锁定客户端 IP,并记录锁定的时间。
/*** 密码登陆认证方式*/
@Slf4j
@Component("passwordAuthMethod")
public class PasswordAuthMethod implements AuthMethod {@Autowiredprivate IUserService userService;@Qualifier("stringRedisTemplate")@Autowiredprivate RedisTemplate<String, String> redisTemplate;/*** 对客户端输入的账号密码校验:先校验账号是否存在,再校验密码是否正确*/@Overridepublic String doAuthenticate(LoginEntity loginEntity) {String clientIp = loginEntity.getClientIp();String username = loginEntity.getAuth().getIdentity().getPassword().getUser().getName();String domainId = loginEntity.getAuth().getIdentity().getPassword().getUser().getDomain().getId();String originalPassword = loginEntity.getAuth().getIdentity().getPassword().getUser().getOriginalPwd();// 根据账号名称查询账号信息UserEntity userInfo = userService.findUserByNameAndDomainId(username, domainId);// 账号不存在登录失败,将尝试登录次数加1,并判断是否锁定客户端IPif (Objects.isNull(userInfo)) {log.info("the domainId:{}, account:{}, not exist", domainId, username);recordLoginAttempts(clientIp);return StringUtils.EMPTY;}// 进行密码的比对,传输过来的是明文String userId = userInfo.getId();String dbEncryptPwd = userInfo.getPassword();boolean isPassed = BcryptUtil.bEncryptMatch(originalPassword, dbEncryptPwd);// 密码错误登录失败,将尝试登录次数加1,并判断是否锁定客户端IPif (!isPassed) {log.info("the user: {} login failed, account or password is wrong", userId);recordLoginAttempts(clientIp);return StringUtils.EMPTY;}// 账号存在且密码正确登陆成功,去除客户端锁定的限制String authIpAddressKey = RedisKeyUtil.getAuthIpAddressKey(clientIp);redisTemplate.delete(authIpAddressKey);// 密码校验成功后返回userIdreturn userId;}/*** 记录客户端登录失败次数*/private void recordLoginAttempts(String ip) {// 获取客户端IP的keyString authIpAddressKey = RedisKeyUtil.getAuthIpAddressKey(ip);// 客户端登录失败次数加1,并设置过期时间为5分钟HashOperations<String, String, String> hashOperations = redisTemplate.opsForHash();int attempts = hashOperations.increment(authIpAddressKey, Constants.NgsocAuth.ATTEMPTS, 1).intValue();PasswordPolicyEntity policyEntity = userService.readPasswordPolicy();redisTemplate.expire(authIpAddressKey, policyEntity.getLockTime(), TimeUnit.MINUTES);// 如果客户端登录失败次数大于等于5,则将客户端IP锁定,设置value当前时间,过期时间为5分钟if (attempts >= policyEntity.getErrorTimes()) {hashOperations.put(authIpAddressKey, Constants.NgsocAuth.NEXT, String.valueOf(LocalDateUtils.getNowMilliseconds()));redisTemplate.expire(authIpAddressKey, policyEntity.getLockTime(), TimeUnit.MINUTES);}}
}
记录登录错误次数:
- 通过
redisTemplate.opsForHash().increment
方法,将 IP 地址对应的登录错误次数递增 1,并将其存储在 Redis 的哈希结构中,键为authIpAddressKey
,字段为Constants.NgsocAuth.ATTEMPTS
。
获取密码策略:
- 使用
userService.readPasswordPolicy()
获取密码策略,包含锁定时间(lockTime
)和允许的最大错误次数(errorTimes
)。
设置 Redis 键过期时间:
- 每次记录登录失败后,都会设置或更新该 IP 地址的 Redis 键的过期时间,过期时间是根据密码策略中的
lockTime
决定的,单位为分钟。
超过错误次数,记录锁定时间:
- 如果错误次数超过策略中定义的最大次数,则在 Redis 中记录锁定时间(
NEXT
),以便在后续检查时根据该时间判断是否锁定 IP 地址。
5. 短信验证码认证方式 SmsAuthMethod
@Component("smsAuthMethod")
public class SmsAuthMethod implements AuthMethod {@Overridepublic String doAuthenticate(LoginEntity loginEntity) {return null;}
}
6. 身份认证责任链 AuthMethodChain
public class AuthMethodChain {private final List<AuthMethod> authMethodList = new ArrayList<>();public AuthMethodChain add(AuthMethod authMethod) {authMethodList.add(authMethod);return this;}/*** 获取用户ID*/public String execute(LoginEntity loginEntity) {String userId = StringUtils.EMPTY;Set<String> userIdSet = new HashSet<>();for (AuthMethod chain : authMethodList) {userId = chain.doAuthenticate(loginEntity);// 只要有一个校验不通过则返回if (StringUtils.isBlank(userId)) {return StringUtils.EMPTY;}// 校验返回的不是同一个用户则不通过userIdSet.add(userId);if (userIdSet.size() > 1) {return StringUtils.EMPTY;}}return userId;}
}
7. 进行多因子登陆认证的校验
在实现多因子登录认证时,使用责任链模式是一种优雅的解决方案。责任链模式通过将一系列的身份认证步骤(例如密码验证、短信验证码验证、二次确认等)串联起来,每个步骤负责处理一部分认证逻辑,若该步骤无法完成认证,则将任务传递给下一个步骤。
通过责任链模式,实现了灵活且可扩展的多因子身份认证机制。每个认证步骤各自负责一部分逻辑,并按照顺序串联起来,如果某一步认证失败,链条会立即中断,从而简化了认证流程的管理逻辑。
可以轻松地增加新的认证步骤(如生物识别、二维码认证等),只需实现 AuthMethod 接口并插入责任链中即可。
@Slf4j
@Service("loginService")
public class LoginServiceImpl implements LoginService {@Resource(name = "passwordAuthMethod")private AuthMethod passwordAuthMethod;@Resource(name = "smsAuthMethod")private AuthMethod smsAuthMethod;/*** 登陆校验*/@Overridepublic LoginResult checkLogin(LoginEntity loginEntity, HttpServletRequest request) {LoginResult loginResult = new LoginResult();String clientIp = ClientUtils.getClientIp(request);// 读取密码策略PasswordPolicyEntity policyEntity = userService.readPasswordPolicy();// 登录错误次数限定if (isLocked(clientIp)) {loginResult.setStatus(I18nUtils.i18n("response.login.lock.time", policyEntity.getErrorTimes(), policyEntity.getLockTime()));return loginResult;}// 进行多因子登陆认证的校验List<String> authMethodList = loginEntity.getAuth().getIdentity().getMethods();AuthMethodChain authMethodChain = new AuthMethodChain();for (String authMethod : authMethodList) {AuthMethodEnum authMethodEnum = AuthMethodEnum.getByCode(authMethod);switch (authMethodEnum) {// 密码校验case PASSWORD:authMethodChain.add(passwordAuthMethod);break;// 短信验证码校验case SMS:authMethodChain.add(smsAuthMethod);break;default:loginResult.setStatus(I18nUtils.i18n("auth.method.undefined"));return loginResult;}}// 执行认证loginEntity.setClientIp(clientIp);String userId = authMethodChain.execute(loginEntity);// userId为空,说明认证失败if (StringUtils.isBlank(userId)) {loginResult.setStatus(I18nUtils.i18n("response.account.login.failed"));return loginResult;}}
}