feat(auth): personal access tokens for headless / CI integration

This commit is contained in:
matevip 2026-05-02 15:41:34 +08:00
parent 4522dfaebc
commit 3c33422a08
7 changed files with 497 additions and 25 deletions

View File

@ -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) {}
}

View File

@ -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;
}

View File

@ -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> {
}

View File

@ -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) {}
}

View File

@ -13,10 +13,13 @@ import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
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 java.io.IOException;
import java.util.List;
import java.util.Optional;
/**
* JWT 认证过滤器
@ -31,42 +34,86 @@ import java.util.List;
public class JwtAuthFilter extends OncePerRequestFilter {
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
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request);
if (StringUtils.hasText(token)) {
try {
Claims claims = authService.parseClaims(token);
if (claims != null && SecurityContextHolder.getContext().getAuthentication() == null) {
String username = claims.getSubject();
UserEntity user = authService.findByUsername(username);
if (user != null && Boolean.TRUE.equals(user.getEnabled())) {
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 解析失败继续匿名访问
if (StringUtils.hasText(token)
&& SecurityContextHolder.getContext().getAuthentication() == null) {
// RFC-03 Lane I1: prefix-based dispatch PAT plaintext is observably
// "mc_*", JWTs always start with "eyJ" (header b64). Cheap O(1) check
// before the heavier work of parsing the token.
if (token.startsWith(PersonalAccessTokenService.PAT_PREFIX)) {
authenticateWithPat(token);
} else {
authenticateWithJwt(token, 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
* 优先从 Authorization Header 读取其次从 query param 读取用于 SSE

View File

@ -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);

View File

@ -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;