mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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.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)
|
||||
|
||||
@ -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