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

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