feat: add database-backed console login
This commit is contained in:
@@ -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 登录会话,只保存令牌摘要';
|
||||
|
||||
217
internal/storage/mysql/console_auth.go
Normal file
217
internal/storage/mysql/console_auth.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
36
internal/storage/mysql/migrations/000029_console_auth.up.sql
Normal file
36
internal/storage/mysql/migrations/000029_console_auth.up.sql
Normal 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 登录会话,只保存令牌摘要';
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user