mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
feat(auth): personal access tokens for headless / CI integration
This commit is contained in:
parent
4522dfaebc
commit
3c33422a08
@ -0,0 +1,88 @@
|
|||||||
|
package vip.mate.auth.pat;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import vip.mate.auth.model.UserEntity;
|
||||||
|
import vip.mate.auth.service.AuthService;
|
||||||
|
import vip.mate.common.result.R;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-03 Lane I1 — Personal Access Token CRUD endpoints.
|
||||||
|
*
|
||||||
|
* <p>Authenticated callers (JWT or another PAT) manage their own tokens
|
||||||
|
* here. Cross-user access is impossible: every query is scoped to
|
||||||
|
* {@code Authentication.getName()} server-side, so tampering with the
|
||||||
|
* {@code X-User-Id} header has no effect.
|
||||||
|
*
|
||||||
|
* <p>Plaintext is returned exactly once on {@link #create}; subsequent
|
||||||
|
* lookups expose only metadata (id, name, scopes, last_used_at,
|
||||||
|
* expires_at). The DB never stores plaintext at any point.
|
||||||
|
*/
|
||||||
|
@Tag(name = "Personal Access Tokens")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/auth/tokens")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PersonalAccessTokenController {
|
||||||
|
|
||||||
|
private final PersonalAccessTokenService patService;
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
|
@Operation(summary = "List my PATs (metadata only — plaintext is never returned after creation)")
|
||||||
|
@GetMapping
|
||||||
|
public R<List<PersonalAccessTokenEntity>> list(Authentication auth) {
|
||||||
|
UserEntity user = requireUser(auth);
|
||||||
|
return R.ok(patService.listByUser(user.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Mint a new PAT — returned plaintext is shown once and cannot be recovered")
|
||||||
|
@PostMapping
|
||||||
|
public R<Map<String, Object>> create(@RequestBody CreateRequest req, Authentication auth) {
|
||||||
|
UserEntity user = requireUser(auth);
|
||||||
|
PersonalAccessTokenService.CreatedToken created = patService.create(
|
||||||
|
user.getId(),
|
||||||
|
req.name(),
|
||||||
|
req.scopes(),
|
||||||
|
req.expiresAt());
|
||||||
|
// Return plaintext + metadata; UI must surface plaintext immediately
|
||||||
|
// and warn the user it won't be shown again.
|
||||||
|
return R.ok(Map.of(
|
||||||
|
"id", created.id(),
|
||||||
|
"plaintext", created.plaintext(),
|
||||||
|
"name", req.name() == null ? "" : req.name(),
|
||||||
|
"scopes", req.scopes() == null ? "" : req.scopes(),
|
||||||
|
"expiresAt", req.expiresAt() == null ? "" : req.expiresAt()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "Revoke a PAT — soft-delete; further auth attempts with this token will fail")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public R<Void> revoke(@PathVariable Long id, Authentication auth) {
|
||||||
|
UserEntity user = requireUser(auth);
|
||||||
|
patService.revoke(id, user.getId());
|
||||||
|
return R.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserEntity requireUser(Authentication auth) {
|
||||||
|
if (auth == null || auth.getName() == null) {
|
||||||
|
throw new MateClawException("err.auth.unauthenticated", "Authentication required");
|
||||||
|
}
|
||||||
|
UserEntity user = authService.findByUsername(auth.getName());
|
||||||
|
if (user == null) {
|
||||||
|
throw new MateClawException("err.auth.user_not_found",
|
||||||
|
"Authenticated user not found: " + auth.getName());
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inbound DTO for {@link #create}. {@code name} and {@code scopes} are
|
||||||
|
* optional; {@code expiresAt} null means the token never expires until
|
||||||
|
* manually revoked. */
|
||||||
|
public record CreateRequest(String name, String scopes, LocalDateTime expiresAt) {}
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
package vip.mate.auth.pat;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-03 Lane I1 — Personal Access Token entity.
|
||||||
|
*
|
||||||
|
* <p>Plaintext tokens are never persisted; only the SHA-256 hash lives in
|
||||||
|
* {@link #tokenHash}. This way a DB compromise reveals ownership and
|
||||||
|
* scope but not the secret needed to actually authenticate. The user
|
||||||
|
* sees the plaintext exactly once at creation time.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("mate_personal_access_token")
|
||||||
|
public class PersonalAccessTokenEntity {
|
||||||
|
|
||||||
|
@TableId(type = IdType.ASSIGN_ID)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** Owner — joined to mate_user.id; one user may own many tokens. */
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/** Human-readable label so the owner can tell tokens apart in the UI. */
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/** SHA-256 hex of the plaintext, lowercase. UNIQUE indexed for O(1) auth lookups. */
|
||||||
|
private String tokenHash;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comma-separated scope tokens (e.g. {@code "chat:read,chat:write"}).
|
||||||
|
* The first version ships with implicit {@code "*"} scope when null,
|
||||||
|
* matching the user's JWT-equivalent permissions; finer-grained scope
|
||||||
|
* checking lands in a follow-up RFC.
|
||||||
|
*/
|
||||||
|
private String scopes;
|
||||||
|
|
||||||
|
/** Updated on each successful auth — debounced to once per minute by the service. */
|
||||||
|
private LocalDateTime lastUsedAt;
|
||||||
|
|
||||||
|
/** Optional hard expiry. Null = never expires (until manually revoked). */
|
||||||
|
private LocalDateTime expiresAt;
|
||||||
|
|
||||||
|
/** Soft revoke — auth filter rejects disabled tokens immediately. */
|
||||||
|
private Boolean enabled;
|
||||||
|
|
||||||
|
@TableField(fill = FieldFill.INSERT)
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
|
private Integer deleted;
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package vip.mate.auth.pat;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-03 Lane I1 — MyBatis Plus mapper for {@link PersonalAccessTokenEntity}.
|
||||||
|
*
|
||||||
|
* <p>Lookup queries live in the service layer via {@code LambdaQueryWrapper};
|
||||||
|
* the only custom requirement is uniqueness on {@code token_hash}, which is
|
||||||
|
* enforced by the database (UNIQUE index in V76 migration).
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface PersonalAccessTokenMapper extends BaseMapper<PersonalAccessTokenEntity> {
|
||||||
|
}
|
||||||
@ -0,0 +1,217 @@
|
|||||||
|
package vip.mate.auth.pat;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-03 Lane I1 — Personal Access Token service.
|
||||||
|
*
|
||||||
|
* <p>Headless / CI / SDK callers can authenticate without the interactive
|
||||||
|
* JWT login flow. Token plaintext is shown to the user exactly once at
|
||||||
|
* creation time; the database only stores SHA-256 hashes, so a DB
|
||||||
|
* compromise can't be used to authenticate as anyone.
|
||||||
|
*
|
||||||
|
* <p>Token format: {@code mc_<43 url-safe base64 chars>} = ~32 bytes of
|
||||||
|
* entropy from {@link SecureRandom}. The {@code mc_} prefix lets the
|
||||||
|
* auth filter distinguish PAT tokens from JWT tokens by inspection
|
||||||
|
* (JWTs always start with {@code eyJ}).
|
||||||
|
*
|
||||||
|
* <p>{@link #findActiveByPlaintext} is the hot path called on every
|
||||||
|
* authenticated PAT request — kept to a single indexed lookup with no
|
||||||
|
* JOINs. {@link #recordUse} debounces last-used updates to once per
|
||||||
|
* minute per token to avoid hammering the row on busy CI loops.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PersonalAccessTokenService {
|
||||||
|
|
||||||
|
/** RFC-03 Lane I1 — observable prefix for PAT plaintext. */
|
||||||
|
public static final String PAT_PREFIX = "mc_";
|
||||||
|
|
||||||
|
/** Bytes of entropy in each freshly generated token. 32 bytes = 256 bits. */
|
||||||
|
private static final int TOKEN_BYTES = 32;
|
||||||
|
|
||||||
|
/** Throttle window for last-used writes. Loops at >1Hz still get a
|
||||||
|
* freshness signal, but we don't write on every single call. */
|
||||||
|
private static final long LAST_USED_DEBOUNCE_SECONDS = 60;
|
||||||
|
|
||||||
|
private final PersonalAccessTokenMapper mapper;
|
||||||
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a new PAT for {@code userId}. Returns the plaintext exactly once
|
||||||
|
* — there is no way to recover it later from the database.
|
||||||
|
*
|
||||||
|
* @param userId owner id (joined to mate_user.id)
|
||||||
|
* @param name human-readable label, may be null
|
||||||
|
* @param scopes comma-separated scope list, null = "*"
|
||||||
|
* @param expiresAt optional hard expiry, null = never expires
|
||||||
|
* @return the plaintext token; the caller is responsible for surfacing
|
||||||
|
* it to the user and never persisting it server-side
|
||||||
|
*/
|
||||||
|
public CreatedToken create(Long userId, String name, String scopes, LocalDateTime expiresAt) {
|
||||||
|
if (userId == null) {
|
||||||
|
throw new MateClawException("err.auth.pat_user_required",
|
||||||
|
"PAT requires an owning user");
|
||||||
|
}
|
||||||
|
String plaintext = generatePlaintext();
|
||||||
|
String hash = sha256Hex(plaintext);
|
||||||
|
|
||||||
|
PersonalAccessTokenEntity entity = new PersonalAccessTokenEntity();
|
||||||
|
entity.setUserId(userId);
|
||||||
|
entity.setName(name);
|
||||||
|
entity.setTokenHash(hash);
|
||||||
|
entity.setScopes(scopes);
|
||||||
|
entity.setExpiresAt(expiresAt);
|
||||||
|
entity.setEnabled(true);
|
||||||
|
entity.setDeleted(0);
|
||||||
|
mapper.insert(entity);
|
||||||
|
|
||||||
|
log.info("[PAT] Created token id={} userId={} name={} expiresAt={}",
|
||||||
|
entity.getId(), userId, name, expiresAt);
|
||||||
|
return new CreatedToken(entity.getId(), plaintext, entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth-filter hot path — find an enabled, unexpired token whose
|
||||||
|
* {@link PersonalAccessTokenEntity#getTokenHash()} matches the SHA-256
|
||||||
|
* of {@code plaintext}.
|
||||||
|
*
|
||||||
|
* <p>Returns empty for null / blank input, missing prefix, hash miss,
|
||||||
|
* disabled flag, or past-expiry — auth filter doesn't need to
|
||||||
|
* distinguish, it just rejects.
|
||||||
|
*/
|
||||||
|
public Optional<PersonalAccessTokenEntity> findActiveByPlaintext(String plaintext) {
|
||||||
|
if (plaintext == null || plaintext.isBlank()) return Optional.empty();
|
||||||
|
if (!plaintext.startsWith(PAT_PREFIX)) return Optional.empty();
|
||||||
|
String hash = sha256Hex(plaintext);
|
||||||
|
|
||||||
|
PersonalAccessTokenEntity entity = mapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<PersonalAccessTokenEntity>()
|
||||||
|
.eq(PersonalAccessTokenEntity::getTokenHash, hash)
|
||||||
|
.eq(PersonalAccessTokenEntity::getEnabled, true)
|
||||||
|
.eq(PersonalAccessTokenEntity::getDeleted, 0)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
if (entity == null) return Optional.empty();
|
||||||
|
if (entity.getExpiresAt() != null && entity.getExpiresAt().isBefore(LocalDateTime.now())) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record last-used timestamp on the token. Debounced so a CI loop at
|
||||||
|
* 5Hz doesn't write 5x per second; the last-used field is observability,
|
||||||
|
* not a correctness gate.
|
||||||
|
*/
|
||||||
|
public void recordUse(PersonalAccessTokenEntity entity) {
|
||||||
|
if (entity == null || entity.getId() == null) return;
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
if (!shouldRecordUse(entity.getLastUsedAt(), now)) return;
|
||||||
|
try {
|
||||||
|
PersonalAccessTokenEntity update = new PersonalAccessTokenEntity();
|
||||||
|
update.setId(entity.getId());
|
||||||
|
update.setLastUsedAt(now);
|
||||||
|
mapper.updateById(update);
|
||||||
|
entity.setLastUsedAt(now);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Best-effort — never fail an authenticated request because we
|
||||||
|
// can't write a metadata column.
|
||||||
|
log.debug("[PAT] last_used_at write failed for token {}: {}",
|
||||||
|
entity.getId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure debounce predicate — package-private for unit testing without
|
||||||
|
* the MyBatis-Plus stack. Returns true when the call should write,
|
||||||
|
* false when it falls inside the {@link #LAST_USED_DEBOUNCE_SECONDS}
|
||||||
|
* window of the previous write.
|
||||||
|
*/
|
||||||
|
static boolean shouldRecordUse(LocalDateTime lastUsedAt, LocalDateTime now) {
|
||||||
|
if (lastUsedAt == null) return true;
|
||||||
|
return !lastUsedAt.plusSeconds(LAST_USED_DEBOUNCE_SECONDS).isAfter(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List tokens for {@code userId}, ordered most-recently-created first.
|
||||||
|
* Plaintext is never returned — only metadata.
|
||||||
|
*/
|
||||||
|
public List<PersonalAccessTokenEntity> listByUser(Long userId) {
|
||||||
|
if (userId == null) return List.of();
|
||||||
|
return mapper.selectList(new LambdaQueryWrapper<PersonalAccessTokenEntity>()
|
||||||
|
.eq(PersonalAccessTokenEntity::getUserId, userId)
|
||||||
|
.eq(PersonalAccessTokenEntity::getDeleted, 0)
|
||||||
|
.orderByDesc(PersonalAccessTokenEntity::getCreateTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-revoke. Two-step so the owner check is explicit and we don't
|
||||||
|
* rely on a single composite UPDATE for both the WHERE-userId guard
|
||||||
|
* and the SET — easier to test, and protects against a future
|
||||||
|
* refactor accidentally dropping the user_id eq.
|
||||||
|
*
|
||||||
|
* <p>Throws {@code err.auth.pat_not_found} for both "token doesn't exist"
|
||||||
|
* and "token belongs to another user" — the unified error code keeps
|
||||||
|
* us from leaking which token ids exist across user boundaries.
|
||||||
|
*/
|
||||||
|
public void revoke(Long tokenId, Long ownerUserId) {
|
||||||
|
if (tokenId == null || ownerUserId == null) return;
|
||||||
|
PersonalAccessTokenEntity existing = mapper.selectById(tokenId);
|
||||||
|
if (existing == null
|
||||||
|
|| existing.getDeleted() != null && existing.getDeleted() == 1
|
||||||
|
|| !ownerUserId.equals(existing.getUserId())) {
|
||||||
|
throw new MateClawException("err.auth.pat_not_found",
|
||||||
|
"PAT not found or not owned by current user: " + tokenId);
|
||||||
|
}
|
||||||
|
existing.setEnabled(false);
|
||||||
|
existing.setDeleted(1);
|
||||||
|
mapper.updateById(existing);
|
||||||
|
log.info("[PAT] Revoked token id={} ownerUserId={}", tokenId, ownerUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Package-private so unit tests can compute the hash without going
|
||||||
|
* through the service. */
|
||||||
|
static String sha256Hex(String plaintext) {
|
||||||
|
try {
|
||||||
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] digest = md.digest(plaintext.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return HexFormat.of().formatHex(digest);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 unavailable on this JVM", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a fresh PAT plaintext. Format: {@value PAT_PREFIX} +
|
||||||
|
* URL-safe base64 of {@value TOKEN_BYTES} bytes from {@link SecureRandom}.
|
||||||
|
* Package-private so tests can verify the prefix + length without
|
||||||
|
* minting a real token through the public API.
|
||||||
|
*/
|
||||||
|
String generatePlaintext() {
|
||||||
|
byte[] bytes = new byte[TOKEN_BYTES];
|
||||||
|
secureRandom.nextBytes(bytes);
|
||||||
|
String body = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
return PAT_PREFIX + body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return value of {@link #create} — entity (sans plaintext) plus the
|
||||||
|
* one-shot plaintext the user must save now. */
|
||||||
|
public record CreatedToken(Long id, String plaintext, PersonalAccessTokenEntity entity) {}
|
||||||
|
}
|
||||||
@ -13,10 +13,13 @@ import org.springframework.stereotype.Component;
|
|||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
import vip.mate.auth.model.UserEntity;
|
import vip.mate.auth.model.UserEntity;
|
||||||
|
import vip.mate.auth.pat.PersonalAccessTokenEntity;
|
||||||
|
import vip.mate.auth.pat.PersonalAccessTokenService;
|
||||||
import vip.mate.auth.service.AuthService;
|
import vip.mate.auth.service.AuthService;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JWT 认证过滤器
|
* JWT 认证过滤器
|
||||||
@ -31,42 +34,86 @@ import java.util.List;
|
|||||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
|
/**
|
||||||
|
* RFC-03 Lane I1 — Personal Access Token service for the headless /
|
||||||
|
* CI / SDK auth path. Optional in the constructor sense but Spring
|
||||||
|
* always injects since the bean is auto-discovered; declared as a
|
||||||
|
* required dependency so unit tests of this filter must wire it.
|
||||||
|
*/
|
||||||
|
private final PersonalAccessTokenService patService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doFilterInternal(HttpServletRequest request,
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
HttpServletResponse response,
|
HttpServletResponse response,
|
||||||
FilterChain filterChain) throws ServletException, IOException {
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
String token = extractToken(request);
|
String token = extractToken(request);
|
||||||
if (StringUtils.hasText(token)) {
|
if (StringUtils.hasText(token)
|
||||||
try {
|
&& SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||||
Claims claims = authService.parseClaims(token);
|
// RFC-03 Lane I1: prefix-based dispatch — PAT plaintext is observably
|
||||||
if (claims != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
// "mc_*", JWTs always start with "eyJ" (header b64). Cheap O(1) check
|
||||||
String username = claims.getSubject();
|
// before the heavier work of parsing the token.
|
||||||
UserEntity user = authService.findByUsername(username);
|
if (token.startsWith(PersonalAccessTokenService.PAT_PREFIX)) {
|
||||||
if (user != null && Boolean.TRUE.equals(user.getEnabled())) {
|
authenticateWithPat(token);
|
||||||
var auth = new UsernamePasswordAuthenticationToken(
|
} else {
|
||||||
username, null,
|
authenticateWithJwt(token, response);
|
||||||
List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().toUpperCase()))
|
|
||||||
);
|
|
||||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
|
||||||
|
|
||||||
// 滑动窗口续期:Token 接近过期时自动签发新 Token
|
|
||||||
if (authService.isNearExpiry(claims)) {
|
|
||||||
String newToken = authService.renewToken(username);
|
|
||||||
if (newToken != null) {
|
|
||||||
response.setHeader("X-New-Token", newToken);
|
|
||||||
response.setHeader("Access-Control-Expose-Headers", "X-New-Token");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
// Token 解析失败,继续匿名访问
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC-03 Lane I1 — PAT auth path. Looks up the token by SHA-256 hash,
|
||||||
|
* loads the owning user, and stamps the SecurityContext identically to
|
||||||
|
* the JWT path so downstream {@code @PreAuthorize} / {@code Authentication}
|
||||||
|
* usages don't need to special-case PAT vs JWT auth.
|
||||||
|
*/
|
||||||
|
private void authenticateWithPat(String plaintext) {
|
||||||
|
try {
|
||||||
|
Optional<PersonalAccessTokenEntity> maybe = patService.findActiveByPlaintext(plaintext);
|
||||||
|
if (maybe.isEmpty()) return;
|
||||||
|
PersonalAccessTokenEntity pat = maybe.get();
|
||||||
|
UserEntity user = authService.findById(pat.getUserId());
|
||||||
|
if (user == null || !Boolean.TRUE.equals(user.getEnabled())) return;
|
||||||
|
|
||||||
|
var auth = new UsernamePasswordAuthenticationToken(
|
||||||
|
user.getUsername(), null,
|
||||||
|
List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().toUpperCase()))
|
||||||
|
);
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||||
|
patService.recordUse(pat); // debounced inside the service
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Anonymous fall-through — same behavior as JWT parse failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Original JWT auth path, factored out to keep doFilterInternal flat. */
|
||||||
|
private void authenticateWithJwt(String token, HttpServletResponse response) {
|
||||||
|
try {
|
||||||
|
Claims claims = authService.parseClaims(token);
|
||||||
|
if (claims == null) return;
|
||||||
|
String username = claims.getSubject();
|
||||||
|
UserEntity user = authService.findByUsername(username);
|
||||||
|
if (user == null || !Boolean.TRUE.equals(user.getEnabled())) return;
|
||||||
|
|
||||||
|
var auth = new UsernamePasswordAuthenticationToken(
|
||||||
|
username, null,
|
||||||
|
List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().toUpperCase()))
|
||||||
|
);
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||||
|
|
||||||
|
// 滑动窗口续期:Token 接近过期时自动签发新 Token
|
||||||
|
if (authService.isNearExpiry(claims)) {
|
||||||
|
String newToken = authService.renewToken(username);
|
||||||
|
if (newToken != null) {
|
||||||
|
response.setHeader("X-New-Token", newToken);
|
||||||
|
response.setHeader("Access-Control-Expose-Headers", "X-New-Token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// Token 解析失败,继续匿名访问
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从请求中提取 Token
|
* 从请求中提取 Token
|
||||||
* 优先从 Authorization Header 读取,其次从 query param 读取(用于 SSE)
|
* 优先从 Authorization Header 读取,其次从 query param 读取(用于 SSE)
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
-- V76: Personal Access Token (RFC-03 Lane I1).
|
||||||
|
-- Lets headless / CI / SDK callers authenticate without going through
|
||||||
|
-- the interactive JWT login flow. Tokens are stored as SHA-256 hashes —
|
||||||
|
-- a DB compromise reveals which user owns which token but never the
|
||||||
|
-- plaintext value the user sees once at creation time.
|
||||||
|
--
|
||||||
|
-- token_hash is the lookup key (UNIQUE) so the auth filter can do a
|
||||||
|
-- single indexed query on every authenticated request.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mate_personal_access_token (
|
||||||
|
id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
name VARCHAR(64),
|
||||||
|
token_hash CHAR(64) NOT NULL,
|
||||||
|
scopes VARCHAR(255),
|
||||||
|
last_used_at TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP,
|
||||||
|
enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
create_time TIMESTAMP,
|
||||||
|
update_time TIMESTAMP,
|
||||||
|
deleted INT DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_pat_token_hash ON mate_personal_access_token(token_hash);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pat_user_id ON mate_personal_access_token(user_id);
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
-- V76: Personal Access Token (RFC-03 Lane I1).
|
||||||
|
-- Lets headless / CI / SDK callers authenticate without going through
|
||||||
|
-- the interactive JWT login flow. Tokens are stored as SHA-256 hashes —
|
||||||
|
-- a DB compromise reveals which user owns which token but never the
|
||||||
|
-- plaintext value the user sees once at creation time.
|
||||||
|
--
|
||||||
|
-- token_hash is the lookup key (UNIQUE) so the auth filter can do a
|
||||||
|
-- single indexed query on every authenticated request.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mate_personal_access_token (
|
||||||
|
id BIGINT NOT NULL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
name VARCHAR(64),
|
||||||
|
token_hash CHAR(64) NOT NULL,
|
||||||
|
scopes VARCHAR(255),
|
||||||
|
last_used_at TIMESTAMP NULL,
|
||||||
|
expires_at TIMESTAMP NULL,
|
||||||
|
enabled BOOLEAN DEFAULT TRUE,
|
||||||
|
create_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_time TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted INT DEFAULT 0,
|
||||||
|
UNIQUE KEY uk_pat_token_hash (token_hash),
|
||||||
|
KEY idx_pat_user_id (user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
Loading…
Reference in New Issue
Block a user