175 lines
6.5 KiB
Go
175 lines
6.5 KiB
Go
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
|
|
policy consoleauth.LoginPolicy
|
|
}
|
|
|
|
// NewConsoleAuthStore 创建控制台认证存储。
|
|
func NewConsoleAuthStore(db *sql.DB, policies ...consoleauth.LoginPolicy) *ConsoleAuthStore {
|
|
policy := consoleauth.DefaultLoginPolicy()
|
|
if len(policies) > 0 {
|
|
policy = policies[0]
|
|
}
|
|
return &ConsoleAuthStore{db: db, policy: policy}
|
|
}
|
|
func (s *ConsoleAuthStore) LoginPolicy() consoleauth.LoginPolicy { return s.policy }
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|