feat(auth): 接入 DCAuth v2 登录并完善成员管理

- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名
- 迁移 014:新增 login_sms_codes / user_devices / device_challenges 与 users.phone
- 新增 /api/dcauth/authorize 与 challenge 状态轮询;旧密码登录仅放行未绑定手机号的账号
- 成员管理支持手机号绑定、设备重置、账号改名、昵称与重置密码
- 移除最后一个商户关系时软删除账号,释放用户名、手机号并撤销设备绑定
This commit is contained in:
yml2213
2026-10-21 22:37:49 +08:00
parent aa052d420a
commit bab522bcb3
29 changed files with 5490 additions and 1170 deletions
@@ -0,0 +1,98 @@
-- ============================================================================
-- 014_login_sms_device_auth.sql
-- 用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名登录。
-- 未绑定手机号的历史账号仍可使用原密码登录。
-- ============================================================================
ALTER TABLE users
ADD COLUMN IF NOT EXISTS phone VARCHAR(32) NOT NULL DEFAULT '';
ALTER TABLE users
ADD COLUMN IF NOT EXISTS phone_bound_at TIMESTAMPTZ;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS phone_bound_by BIGINT;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_users_phone_bound_by'
) THEN
ALTER TABLE users
ADD CONSTRAINT fk_users_phone_bound_by
FOREIGN KEY (phone_bound_by) REFERENCES users(id) ON DELETE SET NULL;
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_phone
ON users(phone)
WHERE phone <> '';
CREATE TABLE IF NOT EXISTS login_sms_codes (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
phone VARCHAR(32) NOT NULL,
purpose VARCHAR(32) NOT NULL DEFAULT 'login',
code_hash VARCHAR(64) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
failed_attempt_count INTEGER NOT NULL DEFAULT 0,
request_ip VARCHAR(64) NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_login_sms_codes_user_created
ON login_sms_codes(user_id, purpose, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_login_sms_codes_phone_created
ON login_sms_codes(phone, created_at DESC);
CREATE TABLE IF NOT EXISTS user_devices (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_id VARCHAR(128) NOT NULL,
public_key VARCHAR(128) NOT NULL,
client_version VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'active',
bound_at TIMESTAMPTZ NOT NULL,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
revoked_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_devices_active_user
ON user_devices(user_id)
WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_user_devices_device_id
ON user_devices(device_id);
CREATE TABLE IF NOT EXISTS device_challenges (
id BIGSERIAL PRIMARY KEY,
challenge_id VARCHAR(128) NOT NULL UNIQUE,
app VARCHAR(64) NOT NULL,
purpose VARCHAR(32) NOT NULL DEFAULT 'login',
challenge VARCHAR(512) NOT NULL,
poll_token_hash VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
device_id VARCHAR(128) NOT NULL DEFAULT '',
public_key VARCHAR(128) NOT NULL DEFAULT '',
client_version VARCHAR(64) NOT NULL DEFAULT '',
device_ticket_hash VARCHAR(64) NOT NULL DEFAULT '',
expires_at TIMESTAMPTZ NOT NULL,
authorized_at TIMESTAMPTZ,
ticket_issued_at TIMESTAMPTZ,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_device_challenges_expires
ON device_challenges(expires_at);
CREATE INDEX IF NOT EXISTS idx_device_challenges_user
ON device_challenges(user_id, created_at DESC);
COMMENT ON COLUMN users.phone IS '账号绑定手机号,用于登录短信验证码';
COMMENT ON COLUMN users.phone_bound_by IS '执行手机号绑定的管理员用户 ID';
COMMENT ON TABLE login_sms_codes IS '登录短信验证码(哈希存储、一次性消费)';
COMMENT ON TABLE user_devices IS '账号绑定的 DCAuth 设备(公钥指纹)';
COMMENT ON TABLE device_challenges IS 'DCAuth v2 设备认证 challenge 与一次性 ticket';