From 3c33422a08286ca0acc4126464b10a723f109024 Mon Sep 17 00:00:00 2001 From: matevip Date: Sat, 2 May 2026 15:41:34 +0800 Subject: [PATCH] feat(auth): personal access tokens for headless / CI integration --- .../pat/PersonalAccessTokenController.java | 88 +++++++ .../auth/pat/PersonalAccessTokenEntity.java | 56 +++++ .../auth/pat/PersonalAccessTokenMapper.java | 15 ++ .../auth/pat/PersonalAccessTokenService.java | 217 ++++++++++++++++++ .../java/vip/mate/config/JwtAuthFilter.java | 97 ++++++-- .../h2/V76__personal_access_token.sql | 25 ++ .../mysql/V76__personal_access_token.sql | 24 ++ 7 files changed, 497 insertions(+), 25 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/auth/pat/PersonalAccessTokenController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/auth/pat/PersonalAccessTokenEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/auth/pat/PersonalAccessTokenMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/auth/pat/PersonalAccessTokenService.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V76__personal_access_token.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V76__personal_access_token.sql diff --git a/mateclaw-server/src/main/java/vip/mate/auth/pat/PersonalAccessTokenController.java b/mateclaw-server/src/main/java/vip/mate/auth/pat/PersonalAccessTokenController.java new file mode 100644 index 00000000..b945f455 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/pat/PersonalAccessTokenController.java @@ -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. + * + *

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. + * + *

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(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> 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 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) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/pat/PersonalAccessTokenEntity.java b/mateclaw-server/src/main/java/vip/mate/auth/pat/PersonalAccessTokenEntity.java new file mode 100644 index 00000000..f23cf384 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/pat/PersonalAccessTokenEntity.java @@ -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. + * + *

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; +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/pat/PersonalAccessTokenMapper.java b/mateclaw-server/src/main/java/vip/mate/auth/pat/PersonalAccessTokenMapper.java new file mode 100644 index 00000000..a30dcefd --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/pat/PersonalAccessTokenMapper.java @@ -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}. + * + *

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 { +} diff --git a/mateclaw-server/src/main/java/vip/mate/auth/pat/PersonalAccessTokenService.java b/mateclaw-server/src/main/java/vip/mate/auth/pat/PersonalAccessTokenService.java new file mode 100644 index 00000000..958f7177 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/auth/pat/PersonalAccessTokenService.java @@ -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. + * + *

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. + * + *

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}). + * + *

{@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}. + * + *

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 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() + .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 listByUser(Long userId) { + if (userId == null) return List.of(); + return mapper.selectList(new LambdaQueryWrapper() + .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. + * + *

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) {} +} diff --git a/mateclaw-server/src/main/java/vip/mate/config/JwtAuthFilter.java b/mateclaw-server/src/main/java/vip/mate/config/JwtAuthFilter.java index b22b357e..8a5b17ab 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/JwtAuthFilter.java +++ b/mateclaw-server/src/main/java/vip/mate/config/JwtAuthFilter.java @@ -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 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) diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V76__personal_access_token.sql b/mateclaw-server/src/main/resources/db/migration/h2/V76__personal_access_token.sql new file mode 100644 index 00000000..e2ee36c1 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V76__personal_access_token.sql @@ -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); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V76__personal_access_token.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V76__personal_access_token.sql new file mode 100644 index 00000000..c7630300 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V76__personal_access_token.sql @@ -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;