feat: add database-backed console login

This commit is contained in:
2026-09-21 04:53:54 +08:00
parent 3e1db5e30d
commit a6f4aa229d
17 changed files with 781 additions and 84 deletions

View File

@@ -814,3 +814,41 @@ CREATE TABLE `weave_waits` (
CONSTRAINT `ck_weave_waits_1` CHECK ((`state` in (_utf8mb4'WAITING',_utf8mb4'SATISFIED',_utf8mb4'EXPIRED',_utf8mb4'CANCELLED'))),
CONSTRAINT `ck_weave_waits_2` CHECK ((`kind` in (_utf8mb4'SIGNAL',_utf8mb4'TIMER',_utf8mb4'POLL')))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='可恢复的信号或时间等待,不长期占用业务 Worker';
-- Orbit 控制台账号与会话;账号只用于人登录,业务 SDK 仍使用命名空间凭据。
CREATE TABLE orbit_console_users (
id BINARY(16) NOT NULL COMMENT '控制台账号标识,全局唯一',
namespace_id BINARY(16) NOT NULL COMMENT '登录后可访问的命名空间',
credential_id BINARY(16) NOT NULL COMMENT '登录后代表该账号调用 API 的业务凭据',
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC',
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后更新时间,统一 UTC',
username VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT '登录名,大小写敏感',
display_name VARCHAR(128) NOT NULL COMMENT '展示名称',
password_hash VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'bcrypt 密码哈希,不保存明文',
enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否允许登录',
PRIMARY KEY (id),
UNIQUE KEY uq_console_username (username),
KEY ix_console_user_principal (namespace_id, credential_id),
CONSTRAINT ck_console_users_0 CHECK (revision > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='控制台人类账号,不替代业务 SDK 凭据';
CREATE TABLE orbit_console_sessions (
id BINARY(16) NOT NULL COMMENT '会话记录标识',
user_id BINARY(16) NOT NULL COMMENT '控制台账号标识',
namespace_id BINARY(16) NOT NULL COMMENT '会话对应命名空间',
credential_id BINARY(16) NOT NULL COMMENT '会话对应业务凭据',
token_digest BINARY(32) NOT NULL COMMENT '会话令牌摘要,不保存令牌原文',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC',
expires_at DATETIME(6) NOT NULL COMMENT '会话过期时间,统一 UTC',
last_seen_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最近一次使用时间,统一 UTC',
revoked_at DATETIME(6) NULL COMMENT '撤销时间;为空表示未撤销',
user_agent VARCHAR(512) NOT NULL DEFAULT '' COMMENT '登录时的客户端摘要',
remote_addr VARCHAR(64) NOT NULL DEFAULT '' COMMENT '登录时的来源地址',
PRIMARY KEY (id),
UNIQUE KEY uq_console_session_token (token_digest),
KEY ix_console_session_user (user_id, revoked_at, expires_at),
KEY ix_console_session_expiry (expires_at, revoked_at),
CONSTRAINT ck_console_sessions_0 CHECK (expires_at > created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='控制台 HttpOnly 登录会话,只保存令牌摘要';

View File

@@ -0,0 +1,217 @@
package mysql
import (
"context"
"database/sql"
"errors"
"strings"
"time"
"unicode/utf8"
"git.yuntuo.hk/k2safe/orbit/internal/consoleauth"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
"golang.org/x/crypto/bcrypt"
)
const (
// ConsoleSessionLifetime 是控制台登录会话的最长空闲前有效期。
ConsoleSessionLifetime = 12 * time.Hour
// 登录不是业务热路径,使用高于 bcrypt 默认值的成本换取更高的离线破解成本。
consolePasswordCost = 12
)
// ConsoleAuthStore 管理控制台的人类账号和 HttpOnly 会话。
// 账号绑定一个已有的 Orbit 业务凭据,登录后沿用该凭据的权限范围;
// 因此控制台登录不会改变 SDK 令牌,也不会绕过原有的 API 授权。
type ConsoleAuthStore struct{ db *sql.DB }
// NewConsoleAuthStore 创建控制台认证存储。
func NewConsoleAuthStore(db *sql.DB) *ConsoleAuthStore { return &ConsoleAuthStore{db: db} }
var _ consoleauth.Store = (*ConsoleAuthStore)(nil)
// ConsoleUser 和 ConsoleLogin 由 HTTP/数据库共享,避免认证实现与路由互相依赖。
type ConsoleUser = consoleauth.User
type ConsoleLogin = consoleauth.LoginResult
// ConsoleUserInput 描述创建控制台账号所需的管理输入。
// NamespaceID 和 CredentialID 必须指向同一命名空间下已存在的业务凭据。
type ConsoleUserInput struct {
NamespaceID core.ID
CredentialID core.ID
Username string
DisplayName string
Password string
}
// CreateConsoleUser 创建一个控制台账号,只保存 bcrypt 哈希,不保存密码原文。
func (s *ConsoleAuthStore) CreateConsoleUser(ctx context.Context, in ConsoleUserInput) (ConsoleUser, error) {
if s == nil || s.db == nil {
return ConsoleUser{}, errors.New("console auth database unavailable")
}
if err := validateConsoleUserInput(in); err != nil {
return ConsoleUser{}, err
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), consolePasswordCost)
if err != nil {
return ConsoleUser{}, err
}
id, err := core.NewID()
if err != nil {
return ConsoleUser{}, err
}
operationID, err := core.NewID()
if err != nil {
return ConsoleUser{}, err
}
name := strings.TrimSpace(in.DisplayName)
if name == "" {
name = in.Username
}
if err := CheckSchema(ctx, s.db); err != nil {
return ConsoleUser{}, err
}
err = WithTx(ctx, s.db, func(tx *sql.Tx) error {
result, err := tx.ExecContext(ctx, `INSERT INTO orbit_console_users
(id,namespace_id,credential_id,username,display_name,password_hash)
SELECT ?,?,?,?,?,? FROM orbit_credentials c
JOIN orbit_namespaces n ON n.id=c.namespace_id
JOIN orbit_applications a ON a.id=n.application_id
WHERE c.namespace_id=? AND c.id=? AND c.revoked_at IS NULL
AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6))
AND n.enabled=TRUE AND a.enabled=TRUE`,
id, in.NamespaceID, in.CredentialID, in.Username, name, string(hash), in.NamespaceID, in.CredentialID)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
return fault.Conflict
}
return err
}
if affected, err := result.RowsAffected(); err != nil {
return err
} else if affected != 1 {
return fault.NotFound
}
actor, err := databaseActor(ctx, tx)
if err != nil {
return err
}
return appendAuditActor(ctx, tx, in.NamespaceID, actor, operationID, "console_user.create", id, "create console user", nil, map[string]any{
"username": in.Username, "display_name": name, "credential_id": in.CredentialID,
})
})
if err != nil {
return ConsoleUser{}, err
}
return ConsoleUser{ID: string(id), Username: in.Username, DisplayName: name}, nil
}
// Login 校验账号密码并建立一个独立会话。失败时统一返回未认证,避免泄露账号存在性。
func (s *ConsoleAuthStore) Login(ctx context.Context, username, password, userAgent, remoteAddr string) (ConsoleLogin, error) {
if s == nil || s.db == nil || username == "" || password == "" || len(password) > 1024 {
return ConsoleLogin{}, fault.Unauthorized
}
var user ConsoleUser
var userID core.ID
var namespace, credential core.ID
var hash string
var enabled bool
err := s.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.display_name,u.password_hash,u.enabled,u.namespace_id,u.credential_id
FROM orbit_console_users u
JOIN orbit_credentials c ON c.namespace_id=u.namespace_id AND c.id=u.credential_id
JOIN orbit_namespaces n ON n.id=u.namespace_id
JOIN orbit_applications a ON a.id=n.application_id
WHERE u.username=? AND c.revoked_at IS NULL
AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6))
AND n.enabled=TRUE AND a.enabled=TRUE`, username).
Scan(&userID, &user.Username, &user.DisplayName, &hash, &enabled, &namespace, &credential)
if err != nil || !enabled || bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
return ConsoleLogin{}, fault.Unauthorized
}
user.ID = string(userID)
token, secretDigest, err := auth.Issue(namespace, credential)
if err != nil {
return ConsoleLogin{}, err
}
var now time.Time
if err := s.db.QueryRowContext(ctx, "SELECT UTC_TIMESTAMP(6)").Scan(&now); err != nil {
return ConsoleLogin{}, err
}
now = now.UTC()
expires := now.Add(ConsoleSessionLifetime)
sessionID, err := core.NewID()
if err != nil {
return ConsoleLogin{}, err
}
if len(userAgent) > 512 {
userAgent = userAgent[:512]
}
if len(remoteAddr) > 64 {
remoteAddr = remoteAddr[:64]
}
_, err = s.db.ExecContext(ctx, `INSERT INTO orbit_console_sessions
(id,user_id,namespace_id,credential_id,token_digest,expires_at,last_seen_at,user_agent,remote_addr)
VALUES (?,?,?,?,?,?,UTC_TIMESTAMP(6),?,?)`,
sessionID, userID, namespace, credential, secretDigest, expires, userAgent, remoteAddr)
if err != nil {
return ConsoleLogin{}, err
}
return ConsoleLogin{User: user, Token: token, ExpiresAt: expires}, nil
}
// ValidateSession 检查会话是否仍然有效,并更新最近使用时间。
// API 授权还会再次读取该会话对应凭据的权限,不信任浏览器提交的身份字段。
func (s *ConsoleAuthStore) ValidateSession(ctx context.Context, token auth.Token) error {
if s == nil || s.db == nil {
return fault.Unauthorized
}
var one int
err := s.db.QueryRowContext(ctx, `SELECT 1
FROM orbit_console_sessions s
JOIN orbit_console_users u ON u.id=s.user_id
JOIN orbit_credentials c ON c.namespace_id=s.namespace_id AND c.id=s.credential_id
JOIN orbit_namespaces n ON n.id=s.namespace_id
JOIN orbit_applications a ON a.id=n.application_id
WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=?
AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(6)
AND u.enabled=TRUE AND c.revoked_at IS NULL
AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6))
AND n.enabled=TRUE AND a.enabled=TRUE`, token.NamespaceID, token.CredentialID, token.Digest).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return fault.Unauthorized
}
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx, `UPDATE orbit_console_sessions SET last_seen_at=UTC_TIMESTAMP(6)
WHERE namespace_id=? AND credential_id=? AND token_digest=? AND revoked_at IS NULL`, token.NamespaceID, token.CredentialID, token.Digest)
return err
}
// RevokeSession 使当前浏览器会话立即失效;找不到会话时也按成功处理,便于幂等退出。
func (s *ConsoleAuthStore) RevokeSession(ctx context.Context, token auth.Token) error {
if s == nil || s.db == nil {
return nil
}
_, err := s.db.ExecContext(ctx, `UPDATE orbit_console_sessions SET revoked_at=UTC_TIMESTAMP(6)
WHERE namespace_id=? AND credential_id=? AND token_digest=? AND revoked_at IS NULL`, token.NamespaceID, token.CredentialID, token.Digest)
return err
}
func validateConsoleUserInput(in ConsoleUserInput) error {
if in.NamespaceID.Validate() != nil || in.CredentialID.Validate() != nil {
return fault.Invalid("namespace and credential are required")
}
if in.Username == "" || len(in.Username) > 128 || !utf8.ValidString(in.Username) || strings.TrimSpace(in.Username) != in.Username {
return fault.Invalid("username must be 1-128 valid UTF-8 characters")
}
if in.DisplayName != "" && (len(in.DisplayName) > 128 || !utf8.ValidString(in.DisplayName)) {
return fault.Invalid("display name must be at most 128 UTF-8 bytes")
}
if len(in.Password) < 12 || len(in.Password) > 1024 {
return fault.Invalid("password must be 12-1024 bytes")
}
return nil
}

View File

@@ -26,7 +26,7 @@ var migrationFiles embed.FS
//go:embed baseline.sql
var freshInstallBaseline string
const freshInstallBaselineVersion uint64 = 28
const freshInstallBaselineVersion uint64 = 29
type Migration struct {
Version uint64

View File

@@ -0,0 +1,36 @@
-- Orbit 控制台账号与会话;账号只用于人登录,业务 SDK 仍使用命名空间凭据。
CREATE TABLE orbit_console_users (
id BINARY(16) NOT NULL COMMENT '控制台账号标识,全局唯一',
namespace_id BINARY(16) NOT NULL COMMENT '登录后可访问的命名空间',
credential_id BINARY(16) NOT NULL COMMENT '登录后代表该账号调用 API 的业务凭据',
revision BIGINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观并发版本,从 1 开始',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC',
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最后更新时间,统一 UTC',
username VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs NOT NULL COMMENT '登录名,大小写敏感',
display_name VARCHAR(128) NOT NULL COMMENT '展示名称',
password_hash VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT 'bcrypt 密码哈希,不保存明文',
enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否允许登录',
PRIMARY KEY (id),
UNIQUE KEY uq_console_username (username),
KEY ix_console_user_principal (namespace_id, credential_id),
CONSTRAINT ck_console_users_0 CHECK (revision > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='控制台人类账号,不替代业务 SDK 凭据';
CREATE TABLE orbit_console_sessions (
id BINARY(16) NOT NULL COMMENT '会话记录标识',
user_id BINARY(16) NOT NULL COMMENT '控制台账号标识',
namespace_id BINARY(16) NOT NULL COMMENT '会话对应命名空间',
credential_id BINARY(16) NOT NULL COMMENT '会话对应业务凭据',
token_digest BINARY(32) NOT NULL COMMENT '会话令牌摘要,不保存令牌原文',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间,统一 UTC',
expires_at DATETIME(6) NOT NULL COMMENT '会话过期时间,统一 UTC',
last_seen_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '最近一次使用时间,统一 UTC',
revoked_at DATETIME(6) NULL COMMENT '撤销时间;为空表示未撤销',
user_agent VARCHAR(512) NOT NULL DEFAULT '' COMMENT '登录时的客户端摘要',
remote_addr VARCHAR(64) NOT NULL DEFAULT '' COMMENT '登录时的来源地址',
PRIMARY KEY (id),
UNIQUE KEY uq_console_session_token (token_digest),
KEY ix_console_session_user (user_id, revoked_at, expires_at),
KEY ix_console_session_expiry (expires_at, revoked_at),
CONSTRAINT ck_console_sessions_0 CHECK (expires_at > created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_as_cs COMMENT='控制台 HttpOnly 登录会话,只保存令牌摘要';

View File

@@ -58,7 +58,27 @@ func authenticate(ctx context.Context, tx *sql.Tx, token auth.Token) (auth.Permi
return auth.Permissions{}, err
}
if subtle.ConstantTimeCompare(digest[:], token.Digest[:]) != 1 {
return auth.Permissions{}, fault.Unauthorized
// 控制台会话使用独立的短期令牌摘要,不轮换也不覆盖业务 SDK
// 凭据。只有经过会话表、账号、命名空间和应用启停状态联合校验,
// 才允许沿用该业务凭据的权限快照。
var sessionPermissions []byte
sessionErr := tx.QueryRowContext(ctx, `SELECT c.permissions
FROM orbit_console_sessions s
JOIN orbit_console_users u ON u.id=s.user_id AND u.enabled=TRUE
JOIN orbit_credentials c ON c.namespace_id=s.namespace_id AND c.id=s.credential_id
JOIN orbit_namespaces n ON n.id=s.namespace_id AND n.enabled=TRUE
JOIN orbit_applications a ON a.id=n.application_id AND a.enabled=TRUE
WHERE s.namespace_id=? AND s.credential_id=? AND s.token_digest=?
AND s.revoked_at IS NULL AND s.expires_at>UTC_TIMESTAMP(6)
AND c.revoked_at IS NULL AND (c.expires_at IS NULL OR c.expires_at>UTC_TIMESTAMP(6))
FOR SHARE`, token.NamespaceID, token.CredentialID, token.Digest).Scan(&sessionPermissions)
if errors.Is(sessionErr, sql.ErrNoRows) {
return auth.Permissions{}, fault.Unauthorized
}
if sessionErr != nil {
return auth.Permissions{}, sessionErr
}
raw = sessionPermissions
}
var p auth.Permissions
if json.Unmarshal(raw, &p) != nil || p.Validate() != nil {