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 }