fix: harden gateway auth and credential handling
This commit is contained in:
@@ -1,19 +1,26 @@
|
||||
package com.iot.gateway.oauth;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iot.gateway.common.IpUtils;
|
||||
import com.iot.gateway.entity.OpenClient;
|
||||
import com.iot.gateway.mapper.OpenClientMapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@@ -30,20 +37,41 @@ public class OAuth2TokenController {
|
||||
@Autowired
|
||||
private JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
@Operation(summary = "客户端凭据模式换取访问令牌 (Client Credentials Grant)")
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Operation(summary = "客户端凭据模式换取访问令牌 (支持 form-urlencoded 与 application/json)")
|
||||
@PostMapping(value = "/token")
|
||||
public ResponseEntity<Map<String, Object>> issueToken(
|
||||
@RequestParam(value = "grant_type", required = false) String formGrantType,
|
||||
@RequestParam(value = "client_id", required = false) String formClientId,
|
||||
@RequestParam(value = "client_secret", required = false) String formClientSecret,
|
||||
@RequestParam(value = "scope", required = false) String formScope,
|
||||
@RequestBody(required = false) Map<String, String> jsonBody,
|
||||
jakarta.servlet.http.HttpServletRequest request) {
|
||||
|
||||
String grantType = formGrantType != null ? formGrantType : (jsonBody != null ? jsonBody.get("grant_type") : null);
|
||||
String clientId = formClientId != null ? formClientId : (jsonBody != null ? jsonBody.get("client_id") : null);
|
||||
String clientSecret = formClientSecret != null ? formClientSecret : (jsonBody != null ? jsonBody.get("client_secret") : null);
|
||||
String scope = formScope != null ? formScope : (jsonBody != null ? jsonBody.get("scope") : null);
|
||||
String grantType = formGrantType;
|
||||
String clientId = formClientId;
|
||||
String clientSecret = formClientSecret;
|
||||
String scope = formScope;
|
||||
|
||||
// 兼容自适应解析 application/json 请求载荷
|
||||
String contentType = request.getContentType();
|
||||
if ((!StringUtils.hasText(clientId) || !StringUtils.hasText(clientSecret))
|
||||
&& contentType != null && contentType.toLowerCase().contains("application/json")) {
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(request.getInputStream());
|
||||
if (root != null) {
|
||||
if (grantType == null && root.has("grant_type")) grantType = root.get("grant_type").asText();
|
||||
if (clientId == null && root.has("client_id")) clientId = root.get("client_id").asText();
|
||||
if (clientSecret == null && root.has("client_secret")) clientSecret = root.get("client_secret").asText();
|
||||
if (scope == null && root.has("scope")) scope = root.get("scope").asText();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("解析 JSON 格式 OAuth 请求体异常: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> error = new HashMap<>();
|
||||
|
||||
@@ -53,16 +81,36 @@ public class OAuth2TokenController {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
|
||||
}
|
||||
|
||||
if (clientId == null || clientSecret == null) {
|
||||
if (!StringUtils.hasText(clientId) || !StringUtils.hasText(clientSecret)) {
|
||||
error.put("error", "invalid_client");
|
||||
error.put("error_description", "client_id 或 client_secret 不能为空");
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
|
||||
}
|
||||
|
||||
OpenClient client = clientMapper.selectOne(new LambdaQueryWrapper<OpenClient>()
|
||||
.eq(OpenClient::getClientId, clientId));
|
||||
.eq(OpenClient::getClientId, clientId.trim()));
|
||||
|
||||
if (client == null || !client.getClientSecret().equals(clientSecret)) {
|
||||
if (client == null) {
|
||||
error.put("error", "invalid_client");
|
||||
error.put("error_description", "客户端标识或密钥错误");
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
|
||||
}
|
||||
|
||||
// 密钥比对:优先支持 BCrypt 哈希比对;兼顾历史明文并自动平滑升级为 BCrypt 哈希
|
||||
boolean secretMatch = false;
|
||||
String storedSecret = client.getClientSecret();
|
||||
if (storedSecret != null) {
|
||||
if (storedSecret.startsWith("$2a$") || storedSecret.startsWith("$2b$")) {
|
||||
secretMatch = passwordEncoder.matches(clientSecret, storedSecret);
|
||||
} else if (storedSecret.equals(clientSecret)) {
|
||||
secretMatch = true;
|
||||
client.setClientSecret(passwordEncoder.encode(clientSecret));
|
||||
clientMapper.updateById(client);
|
||||
log.info("检测到明文密钥成功验证,已自动无感升级为 BCrypt 哈希: clientId={}", clientId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!secretMatch) {
|
||||
error.put("error", "invalid_client");
|
||||
error.put("error_description", "客户端标识或密钥错误");
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
|
||||
@@ -80,9 +128,9 @@ public class OAuth2TokenController {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
|
||||
}
|
||||
|
||||
// 校验 IP 白名单
|
||||
String clientIp = com.iot.gateway.common.IpUtils.getClientIp(request);
|
||||
if (!com.iot.gateway.common.IpUtils.isAllowed(clientIp, client.getIpWhitelist())) {
|
||||
// 校验客户端来源 IP 白名单(基于可信反向代理验证)
|
||||
String clientIp = IpUtils.getClientIp(request);
|
||||
if (!IpUtils.isAllowed(clientIp, client.getIpWhitelist())) {
|
||||
log.warn("OAuth 2.0 客户端 IP 白名单拦截: clientId={}, clientIp={}, whitelist={}",
|
||||
clientId, clientIp, client.getIpWhitelist());
|
||||
error.put("error", "unauthorized_client");
|
||||
@@ -90,14 +138,38 @@ public class OAuth2TokenController {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
|
||||
}
|
||||
|
||||
String accessToken = jwtTokenProvider.generateToken(client);
|
||||
log.info("OAuth 2.0 成功颁发令牌: clientId={}, clientIp={}, tenantId={}", clientId, clientIp, client.getTenantId());
|
||||
// 验证 Scope 并支持合法收窄 (Scope Narrowing)
|
||||
String effectiveScopes = client.getScopes();
|
||||
if (StringUtils.hasText(scope)) {
|
||||
List<String> clientAllowed = StringUtils.hasText(client.getScopes())
|
||||
? Arrays.asList(client.getScopes().split("[,;\\s]+"))
|
||||
: Collections.emptyList();
|
||||
List<String> requested = Arrays.asList(scope.split("[,;\\s]+"));
|
||||
|
||||
for (String reqScope : requested) {
|
||||
String reqNorm = reqScope.trim().replace(":", ".");
|
||||
boolean allowed = clientAllowed.stream().anyMatch(ca -> {
|
||||
String caNorm = ca.trim().replace(":", ".");
|
||||
return caNorm.equals(reqNorm) || caNorm.equals("all") || caNorm.equals("*");
|
||||
});
|
||||
if (!allowed) {
|
||||
error.put("error", "invalid_scope");
|
||||
error.put("error_description", "请求的作用域 [" + reqScope + "] 超出该客户端的允许授权范围");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
|
||||
}
|
||||
}
|
||||
effectiveScopes = String.join(",", requested);
|
||||
}
|
||||
|
||||
String accessToken = jwtTokenProvider.generateToken(client, effectiveScopes);
|
||||
log.info("OAuth 2.0 成功颁发令牌: clientId={}, clientIp={}, tenantId={}, scopes={}",
|
||||
clientId, clientIp, client.getTenantId(), effectiveScopes);
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("access_token", accessToken);
|
||||
response.put("token_type", "Bearer");
|
||||
response.put("expires_in", jwtTokenProvider.getTokenExpireSeconds());
|
||||
response.put("scope", client.getScopes());
|
||||
response.put("scope", effectiveScopes);
|
||||
response.put("tenant_id", client.getTenantId());
|
||||
response.put("client_name", client.getClientName());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user