diff --git a/client/lib/services/auth_api.dart b/client/lib/services/auth_api.dart index 77a63d1..7b15fbc 100644 --- a/client/lib/services/auth_api.dart +++ b/client/lib/services/auth_api.dart @@ -61,11 +61,13 @@ class AuthApi { required String email, required String code, required String password, + Map? device, }) async { final resp = await _post('/v1/auth/register', { 'email': email, 'code': code, 'password': password, + if (device != null) 'device': device, }); if (resp.statusCode != 200 && resp.statusCode != 201) { _throwFromResponse(resp); @@ -78,10 +80,12 @@ class AuthApi { Future login({ required String email, required String password, + Map? device, }) async { final resp = await _post('/v1/auth/login', { 'email': email, 'password': password, + if (device != null) 'device': device, }); if (resp.statusCode != 200) _throwFromResponse(resp); return AuthTokens.fromJson(jsonDecode(resp.body) as Map); diff --git a/client/lib/services/device_identity.dart b/client/lib/services/device_identity.dart new file mode 100644 index 0000000..8bf1b22 --- /dev/null +++ b/client/lib/services/device_identity.dart @@ -0,0 +1,134 @@ +// device_identity.dart — 设备身份:稳定 device_id + 名称/平台/客户端版本上报 +// +// device_id 首次启动随机生成(UUID v4)、写入 flutter_secure_storage 持久化,之后 +// 每次读取复用 → 同一安装跨重启恒定(替换早期硬编码 'mac-001')。名称/平台/版本 +// 取自系统。随 登录/注册/连接 上报给控制面,用于「我的设备」登记与会话绑定。 +import 'dart:io' show Platform; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:uuid/uuid.dart'; + +/// 设备元数据,随认证/连接请求上报。 +class DeviceMeta { + const DeviceMeta({ + required this.id, + required this.name, + required this.platform, + required this.clientVersion, + }); + + final String id; + final String name; + final String platform; // ios | android | windows | macos | linux + final String clientVersion; + + Map toJson() => { + 'id': id, + 'name': name, + 'platform': platform, + 'client_version': clientVersion, + }; +} + +/// 极简键值存储接缝(便于单测注入;默认走 flutter_secure_storage)。 +abstract class SecureKV { + Future read(String key); + Future write(String key, String value); +} + +class _SecureStorageKV implements SecureKV { + // 与 TokenStore 一致:macOS 文件式 keychain,避免未签名 app 报 -34018。 + static const _s = FlutterSecureStorage( + mOptions: MacOsOptions(useDataProtectionKeyChain: false), + ); + @override + Future read(String key) => _s.read(key: key); + @override + Future write(String key, String value) => _s.write(key: key, value: value); +} + +/// 设备身份服务。device_id 持久于安全存储;其余字段取自系统(缓存)。 +class DeviceIdentity { + DeviceIdentity({SecureKV? store}) : _kv = store ?? _SecureStorageKV(); + + final SecureKV _kv; + static const _kDeviceId = 'pangolin_device_id'; + static const _uuid = Uuid(); + + String? _cachedId; + DeviceMeta? _cachedMeta; + + /// 稳定 device_id。读到→复用;读到空→生成并持久化; + /// 读**失败**(平台异常)≠ 不存在 → 退回进程内临时 id,**不写库**,避免冲掉真实 id。 + Future deviceId() async { + if (_cachedId != null) return _cachedId!; + String? existing; + try { + existing = await _kv.read(_kDeviceId); + } catch (_) { + return _cachedId ??= _uuid.v4(); // 临时,不持久化 + } + if (existing != null && existing.isNotEmpty) return _cachedId = existing; + final id = _uuid.v4(); + try { + await _kv.write(_kDeviceId, id); + } catch (_) { + // 写失败:本次用内存值,下次再尝试持久化。 + } + return _cachedId = id; + } + + /// 完整设备元数据(缓存,首次解析后复用)。 + Future meta() async { + if (_cachedMeta != null) return _cachedMeta!; + final m = DeviceMeta( + id: await deviceId(), + name: await _name(), + platform: currentPlatform(), + clientVersion: await _clientVersion(), + ); + return _cachedMeta = m; + } + + /// 当前平台标识(与服务端 devices.platform 枚举对齐)。 + static String currentPlatform() { + if (Platform.isIOS) return 'ios'; + if (Platform.isAndroid) return 'android'; + if (Platform.isMacOS) return 'macos'; + if (Platform.isWindows) return 'windows'; + if (Platform.isLinux) return 'linux'; + return 'unknown'; + } + + Future _name() async { + try { + if (Platform.isIOS) { + final i = await DeviceInfoPlugin().iosInfo; + return i.name.isNotEmpty ? i.name : i.utsname.machine; + } + if (Platform.isAndroid) { + final a = await DeviceInfoPlugin().androidInfo; + return '${a.manufacturer} ${a.model}'.trim(); + } + // 桌面:主机名最有意义(MacBook-Pro / DESKTOP-XXXX)。 + return Platform.localHostname; + } catch (_) { + return currentPlatform(); + } + } + + Future _clientVersion() async { + try { + final info = await PackageInfo.fromPlatform(); + return 'v${info.version}'; + } catch (_) { + return ''; + } + } +} + +/// 单例 DeviceIdentity(测试可 override)。 +final deviceIdentityProvider = Provider((ref) => DeviceIdentity()); diff --git a/client/lib/state/connection_provider.dart b/client/lib/state/connection_provider.dart index 7804fc7..522b285 100644 --- a/client/lib/state/connection_provider.dart +++ b/client/lib/state/connection_provider.dart @@ -14,18 +14,13 @@ import '../bridge/vpn_bridge_provider.dart'; import '../l10n/app_text.dart'; import '../services/api_config.dart'; import '../services/connect_api.dart'; +import '../services/device_identity.dart'; import 'app_providers.dart'; import 'auth_provider.dart'; import 'nodes_provider.dart'; import 'settings_provider.dart'; -// ── 设备 ID(MVP 常量;后续由 device_info_plus 取真实 ID)────────── - -const _kDeviceId = String.fromEnvironment( - 'PANGOLIN_DEVICE_ID', - defaultValue: 'mac-001', -); - +// 设备 ID 由 deviceIdentityProvider 提供(secure storage 持久化的稳定 UUID)。 // API base URL 统一用 api_config.dart 的 kApiBaseUrl(单一来源,勿再重复声明)。 // ── 连接阶段枚举 ────────────────────────────────────────────────── @@ -133,13 +128,14 @@ class ConnectionController extends StateNotifier { /// 取配置;access token 过期(401)时用 refresh token 续期后**重试一次**。 /// 续期失败(refresh 也过期 / 被拒)由 authProvider.refresh() 触发登出 → UI 回登录页。 Future _fetchConfigWithRefresh(String nodeUuid) async { + final deviceId = await _ref.read(deviceIdentityProvider).deviceId(); Future doFetch() { final token = _ref.read(authProvider).accessToken ?? ''; _api?.dispose(); _api = _ref.read(connectApiFactoryProvider)(token); return _api!.fetchConfig( nodeId: nodeUuid, - deviceId: _kDeviceId, + deviceId: deviceId, // smartRoute 偏好 → 国内分流(#5):国内 IP/域名直连,不走隧道。 splitCN: _ref.read(settingsProvider).smartRoute, ); diff --git a/client/lib/widgets/auth_screen.dart b/client/lib/widgets/auth_screen.dart index 8c26cb1..768216c 100644 --- a/client/lib/widgets/auth_screen.dart +++ b/client/lib/widgets/auth_screen.dart @@ -10,6 +10,7 @@ import '../l10n/app_text.dart'; import '../pangolin_theme.dart'; import '../services/api_config.dart'; import '../services/auth_api.dart'; +import '../services/device_identity.dart'; import '../state/auth_provider.dart'; import 'pangolin_button.dart'; import 'pangolin_icons.dart'; @@ -100,10 +101,12 @@ class _AuthScreenState extends ConsumerState Future _doRegister() async { setState(() { _loading = true; _errorZh = null; }); try { + final device = (await ref.read(deviceIdentityProvider).meta()).toJson(); final tokens = await _api.register( email: _email.text.trim(), code: _code.text.trim(), password: _pw.text, + device: device, ); await ref.read(tokenStoreProvider).saveLastEmail(_email.text.trim()); await ref.read(authProvider.notifier).saveTokens(tokens); @@ -116,9 +119,11 @@ class _AuthScreenState extends ConsumerState Future _doLogin() async { setState(() { _loading = true; _errorZh = null; }); try { + final device = (await ref.read(deviceIdentityProvider).meta()).toJson(); final tokens = await _api.login( email: _email.text.trim(), password: _pw.text, + device: device, ); await ref.read(tokenStoreProvider).saveLastEmail(_email.text.trim()); await ref.read(authProvider.notifier).saveTokens(tokens); diff --git a/client/macos/Flutter/GeneratedPluginRegistrant.swift b/client/macos/Flutter/GeneratedPluginRegistrant.swift index 62fded1..5942a33 100644 --- a/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import device_info_plus import flutter_secure_storage_macos import package_info_plus import screen_retriever_macos @@ -13,6 +14,7 @@ import tray_manager import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) diff --git a/client/pubspec.yaml b/client/pubspec.yaml index c1434d9..80d0fb8 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -17,9 +17,11 @@ dependencies: flutter_riverpod: ^2.5.1 # 状态层(连接状态机 / 免费额度 / 节点选择 / 语言 / 主题) http: ^1.2.1 # 控制面 HTTP 客户端(connect API) path_provider: ^2.1.3 # 获取应用支持目录(sing-box 配置路径) - flutter_secure_storage: ^9.2.2 # JWT token 安全存储 + flutter_secure_storage: ^9.2.2 # JWT token 安全存储 + 稳定 device_id 持久化 shared_preferences: ^2.5.5 package_info_plus: ^9.0.1 + device_info_plus: ^11.2.0 # 设备名/平台(「我的设备」上报) + uuid: ^4.5.1 # 客户端生成稳定 device_id (UUID v4) launch_at_startup: ^0.5.1 tray_manager: ^0.5.3 window_manager: ^0.5.1 diff --git a/client/test/unit/device_identity_test.dart b/client/test/unit/device_identity_test.dart new file mode 100644 index 0000000..f59d029 --- /dev/null +++ b/client/test/unit/device_identity_test.dart @@ -0,0 +1,59 @@ +// device_identity_test.dart — 稳定 device_id 的生成/持久/读失败语义 +import 'package:flutter_test/flutter_test.dart'; +import 'package:pangolin_vpn/services/device_identity.dart'; + +/// 内存版 SecureKV:可模拟读失败,记录写入。 +class _FakeKV implements SecureKV { + _FakeKV({this.failRead = false}); + final Map map = {}; + bool failRead; + int writes = 0; + + @override + Future read(String key) async { + if (failRead) throw Exception('platform read failed'); + return map[key]; + } + + @override + Future write(String key, String value) async { + writes++; + map[key] = value; + } +} + +void main() { + test('首次:生成并持久化;再读复用同一个', () async { + final kv = _FakeKV(); + final d1 = DeviceIdentity(store: kv); + final id1 = await d1.deviceId(); + expect(id1, isNotEmpty); + expect(kv.writes, 1, reason: '首次应写一次'); + expect(kv.map['pangolin_device_id'], id1); + + // 同实例缓存:不再写。 + expect(await d1.deviceId(), id1); + expect(kv.writes, 1); + + // 新实例、同存储:读出同一个,不重新生成。 + final d2 = DeviceIdentity(store: kv); + expect(await d2.deviceId(), id1); + expect(kv.writes, 1, reason: '已存在不应再写'); + }); + + test('读失败 ≠ 不存在:退回临时 id,不写库(不冲掉真实 id)', () async { + final kv = _FakeKV(failRead: true)..map['pangolin_device_id'] = 'real-id'; + final d = DeviceIdentity(store: kv); + final id = await d.deviceId(); + expect(id, isNotEmpty); + expect(id, isNot('real-id'), reason: '读失败拿不到真实值,用临时'); + expect(kv.writes, 0, reason: '读失败绝不写,避免覆盖真实 id'); + // 真实 id 仍在存储里,未被破坏。 + expect(kv.map['pangolin_device_id'], 'real-id'); + }); + + test('生成的是 UUID v4 形态', () async { + final id = await DeviceIdentity(store: _FakeKV()).deviceId(); + expect(RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$').hasMatch(id), isTrue); + }); +} diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index dde58e4..53134ec 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -237,6 +237,12 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv log.Printf("JWT not configured — /v1 protected routes will be unavailable") } + // ── Devices ─────────────────────────────────────────────────────────────── + // Constructed before Auth so login/register can register the device. + devicesStore := devices.NewStore(sqlDB) + devicesSvc := devices.NewService(devicesStore, nil) // NoopRevoker for MVP + devicesHandler := devices.NewHandler(devicesSvc) + // ── Auth ────────────────────────────────────────────────────────────────── var authHandler *auth.Handler if tm != nil { @@ -255,6 +261,7 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv rl := auth.NewRateLimiter(rdb, nil) authStore := auth.NewSQLStore(sqlDB) authSvc := auth.NewService(authStore, rdb, rl, tm, mailer, auth.ServiceConfig{}, nil) + authSvc.SetDeviceRegistrar(authDeviceRegistrar{svc: devicesSvc}) authHandler = auth.NewHandler(authSvc) } @@ -277,11 +284,6 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv webhookHandler := codes.NewWebhookHandler(codesStore, rdb, os.Getenv("WEBHOOK_SECRET"), 5*time.Minute, 15*time.Minute) - // ── Devices ─────────────────────────────────────────────────────────────── - devicesStore := devices.NewStore(sqlDB) - devicesSvc := devices.NewService(devicesStore, nil) // NoopRevoker for MVP - devicesHandler := devices.NewHandler(devicesSvc) - // ── Usage ───────────────────────────────────────────────────────────────── usageStore := usage.NewStore(sqlDB) usageSvc := usage.NewService(usageStore, rdb, nil, time.Hour) @@ -499,3 +501,23 @@ func intEnvDefault(key string, def int) int { } return n } + +// authDeviceRegistrar adapts devices.Service to auth.DeviceRegistrar, keeping the +// auth and devices packages decoupled. Device registration on login/register is +// best-effort with NO cap enforcement (MaxDevices=0): free-plan reinstall churns +// the device UUID, so a hard cap at login would lock users out. Explicit device +// limiting is a separate future policy with its own UX. +type authDeviceRegistrar struct{ svc *devices.Service } + +func (a authDeviceRegistrar) RegisterDevice(ctx context.Context, userID int64, meta auth.DeviceMeta) error { + if _, apiErr := a.svc.RegisterIfAbsent(ctx, devices.RegisterInput{ + UserID: userID, + DeviceUUID: meta.DeviceID, + Name: meta.Name, + Platform: meta.Platform, + MaxDevices: 0, + }); apiErr != nil { + return apiErr + } + return nil +} diff --git a/server/cmd/tmphash/main.go b/server/cmd/tmphash/main.go new file mode 100644 index 0000000..cc987de --- /dev/null +++ b/server/cmd/tmphash/main.go @@ -0,0 +1,27 @@ +// tmphash — 一次性:按服务端 argon2id 参数算密码 hash(临时,不提交)。 +package main + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "os" + + "golang.org/x/crypto/argon2" +) + +func main() { + pw := "wangjia812" + if len(os.Args) > 1 { + pw = os.Args[1] + } + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + panic(err) + } + key := argon2.IDKey([]byte(pw), salt, uint32(1), uint32(65536), uint8(4), uint32(32)) + fmt.Printf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s\n", + argon2.Version, 65536, 1, 4, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(key)) +} diff --git a/server/internal/auth/device_register_test.go b/server/internal/auth/device_register_test.go new file mode 100644 index 0000000..e3053d7 --- /dev/null +++ b/server/internal/auth/device_register_test.go @@ -0,0 +1,89 @@ +package auth + +import ( + "context" + "testing" +) + +// fakeRegistrar records RegisterDevice calls for assertion. +type fakeRegistrar struct { + calls []struct { + userID int64 + meta DeviceMeta + } + err error +} + +func (f *fakeRegistrar) RegisterDevice(_ context.Context, userID int64, meta DeviceMeta) error { + f.calls = append(f.calls, struct { + userID int64 + meta DeviceMeta + }{userID, meta}) + return f.err +} + +// Register/Login with a device should trigger RegisterDevice with the meta. +func TestService_RegisterDevice_OnRegisterAndLogin(t *testing.T) { + svc, _, _ := newService(t, ServiceConfig{}) + reg := &fakeRegistrar{} + svc.SetDeviceRegistrar(reg) + ctx := context.Background() + const email = "dev@example.com" + const pw = "supersecret" + meta := DeviceMeta{DeviceID: "dev-uuid-1", Name: "MacBook Pro", Platform: "macos", ClientVersion: "v1.0.10"} + + if _, err := svc.SendCode(ctx, email, "1.1.1.1"); err != nil { + t.Fatalf("SendCode: %v", err) + } + code := codeInRedis(t, svc, email) + if _, e := svc.Register(ctx, email, code, pw, meta); e != nil { + t.Fatalf("Register: %v", e) + } + if len(reg.calls) != 1 || reg.calls[0].meta.DeviceID != "dev-uuid-1" || reg.calls[0].meta.Platform != "macos" { + t.Fatalf("register did not register device: %+v", reg.calls) + } + + if _, _, e := svc.Login(ctx, email, pw, "", meta); e != nil { + t.Fatalf("Login: %v", e) + } + if len(reg.calls) != 2 || reg.calls[1].meta.Name != "MacBook Pro" { + t.Fatalf("login did not register device: %+v", reg.calls) + } + if reg.calls[0].userID == 0 || reg.calls[0].userID != reg.calls[1].userID { + t.Fatalf("userID mismatch: %+v", reg.calls) + } +} + +// A registrar error (e.g. device cap) must NOT fail login/register. +func TestService_RegisterDevice_BestEffort(t *testing.T) { + svc, _, _ := newService(t, ServiceConfig{}) + svc.SetDeviceRegistrar(&fakeRegistrar{err: context.DeadlineExceeded}) + ctx := context.Background() + const email = "be@example.com" + if _, err := svc.SendCode(ctx, email, "1.1.1.1"); err != nil { + t.Fatalf("SendCode: %v", err) + } + code := codeInRedis(t, svc, email) + if _, e := svc.Register(ctx, email, code, "supersecret", DeviceMeta{DeviceID: "x", Platform: "windows"}); e != nil { + t.Fatalf("Register must succeed despite registrar error: %v", e) + } +} + +// No device id / no registrar → no-op, login still works. +func TestService_RegisterDevice_NoMeta(t *testing.T) { + svc, _, _ := newService(t, ServiceConfig{}) + reg := &fakeRegistrar{} + svc.SetDeviceRegistrar(reg) + ctx := context.Background() + const email = "nm@example.com" + if _, err := svc.SendCode(ctx, email, "1.1.1.1"); err != nil { + t.Fatalf("SendCode: %v", err) + } + code := codeInRedis(t, svc, email) + if _, e := svc.Register(ctx, email, code, "supersecret", DeviceMeta{}); e != nil { + t.Fatalf("Register: %v", e) + } + if len(reg.calls) != 0 { + t.Fatalf("empty device id should not register: %+v", reg.calls) + } +} diff --git a/server/internal/auth/handler.go b/server/internal/auth/handler.go index 5d2bad7..e71349e 100644 --- a/server/internal/auth/handler.go +++ b/server/internal/auth/handler.go @@ -46,15 +46,30 @@ type sendCodeRequest struct { Email string `json:"email"` } +// deviceBody is the optional device identity sent on login/register so the +// control plane can register the device (devices table) and bind a session. +type deviceBody struct { + ID string `json:"id"` + Name string `json:"name"` + Platform string `json:"platform"` + ClientVersion string `json:"client_version"` +} + +func (d deviceBody) toMeta() DeviceMeta { + return DeviceMeta{DeviceID: d.ID, Name: d.Name, Platform: d.Platform, ClientVersion: d.ClientVersion} +} + type registerRequest struct { - Email string `json:"email"` - Code string `json:"code"` - Password string `json:"password"` + Email string `json:"email"` + Code string `json:"code"` + Password string `json:"password"` + Device deviceBody `json:"device"` } type loginRequest struct { - Email string `json:"email"` - Password string `json:"password"` + Email string `json:"email"` + Password string `json:"password"` + Device deviceBody `json:"device"` } type refreshRequest struct { @@ -87,7 +102,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { if !decodeJSON(w, r, &req) { return } - pair, apiErr := h.svc.Register(r.Context(), req.Email, req.Code, req.Password) + pair, apiErr := h.svc.Register(r.Context(), req.Email, req.Code, req.Password, req.Device.toMeta()) if apiErr != nil { writeAPIErr(w, apiErr, 0) return @@ -101,7 +116,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { if !decodeJSON(w, r, &req) { return } - out, retryAfter, apiErr := h.svc.Login(r.Context(), req.Email, req.Password, clientIP(r)) + out, retryAfter, apiErr := h.svc.Login(r.Context(), req.Email, req.Password, clientIP(r), req.Device.toMeta()) if apiErr != nil { writeAPIErr(w, apiErr, retryAfter) return diff --git a/server/internal/auth/service.go b/server/internal/auth/service.go index 98d589f..9e72add 100644 --- a/server/internal/auth/service.go +++ b/server/internal/auth/service.go @@ -77,6 +77,22 @@ func (c *ServiceConfig) withDefaults() { } } +// DeviceMeta is the client-reported device identity carried on login/register so +// the device can be registered (devices table) and, later, bound to a session. +type DeviceMeta struct { + DeviceID string // client-generated stable UUID (secure storage) + Name string // host/model name + Platform string // ios|android|windows|macos|linux + ClientVersion string // app version (stored from P2 onward) +} + +// DeviceRegistrar registers the logging-in device. Defined consumer-side to +// avoid an import cycle; devices.Service is adapted to it in main wiring. +// Registration is best-effort and must never block login (see registerDevice). +type DeviceRegistrar interface { + RegisterDevice(ctx context.Context, userID int64, meta DeviceMeta) error +} + // Service is the auth business layer: code issuance, registration, login, and // token refresh. It is safe for concurrent use. type Service struct { @@ -87,6 +103,24 @@ type Service struct { mailer Mailer cfg ServiceConfig now func() time.Time + devReg DeviceRegistrar // nil until wired; registration is best-effort +} + +// SetDeviceRegistrar wires the device registrar after construction (main keeps +// auth and devices decoupled). Safe to call once during startup. +func (s *Service) SetDeviceRegistrar(r DeviceRegistrar) { s.devReg = r } + +// registerDevice records the logging-in device. Best-effort: a registrar error +// (device cap, transient DB) is logged but never fails the login/registration — +// the user must always be able to get in (notably: free-plan reinstall churns +// the device UUID, so a hard cap here would lock users out). +func (s *Service) registerDevice(ctx context.Context, userID int64, meta DeviceMeta) { + if s.devReg == nil || meta.DeviceID == "" { + return + } + if err := s.devReg.RegisterDevice(ctx, userID, meta); err != nil { + slog.Warn("auth: device register failed (login proceeds)", "uid", userID, "err", err) + } } // NewService wires the auth service. now may be nil (defaults to time.Now). @@ -184,7 +218,7 @@ func (s *Service) SendCode(ctx context.Context, rawEmail, ip string) (retryAfter // Register verifies the code (one-time), creates the account plus a 7-day PRO // trial in a single transaction, and returns a fresh token pair. -func (s *Service) Register(ctx context.Context, rawEmail, code, password string) (*TokenPair, *apierr.Error) { +func (s *Service) Register(ctx context.Context, rawEmail, code, password string, device DeviceMeta) (*TokenPair, *apierr.Error) { email := NormalizeEmail(rawEmail) if !ValidEmail(email) || len(password) < 8 || len(code) != 6 { return nil, ErrInvalidRequest @@ -217,6 +251,7 @@ func (s *Service) Register(ctx context.Context, rawEmail, code, password string) if err != nil { return nil, ErrInternal } + s.registerDevice(ctx, user.ID, device) return pair, nil } @@ -272,7 +307,7 @@ const ( totpPendingTTL = 5 * time.Minute ) -func (s *Service) Login(ctx context.Context, rawEmail, password, ip string) (*LoginOutcome, time.Duration, *apierr.Error) { +func (s *Service) Login(ctx context.Context, rawEmail, password, ip string, device DeviceMeta) (*LoginOutcome, time.Duration, *apierr.Error) { _ = ip // IP reserved for future per-IP login throttling; not logged. email := NormalizeEmail(rawEmail) if email == "" || password == "" { @@ -329,6 +364,7 @@ func (s *Service) Login(ctx context.Context, rawEmail, password, ip string) (*Lo if err != nil { return nil, 0, ErrInternal } + s.registerDevice(ctx, user.ID, device) return &LoginOutcome{Tokens: pair}, 0, nil } diff --git a/server/internal/auth/service_test.go b/server/internal/auth/service_test.go index a682b04..64b776f 100644 --- a/server/internal/auth/service_test.go +++ b/server/internal/auth/service_test.go @@ -40,7 +40,7 @@ func TestService_RegisterFullFlow(t *testing.T) { } code := codeInRedis(t, svc, email) - pair, apiErr := svc.Register(ctx, email, code, "supersecret") + pair, apiErr := svc.Register(ctx, email, code, "supersecret", DeviceMeta{}) if apiErr != nil { t.Fatalf("Register: %v", apiErr) } @@ -84,7 +84,7 @@ func TestService_DuplicateEmailConflict(t *testing.T) { // First registration. _, _ = svc.SendCode(ctx, email, "") - if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1"); e != nil { + if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1", DeviceMeta{}); e != nil { t.Fatalf("first register: %v", e) } @@ -103,7 +103,7 @@ func TestService_DuplicateEmailConflict(t *testing.T) { if err := svc.rdb.Set(ctx, codeKey(email), "654321", 10*time.Minute).Err(); err != nil { t.Fatalf("force code: %v", err) } - _, apiErr := svc.Register(ctx, email, "654321", "password2") + _, apiErr := svc.Register(ctx, email, "654321", "password2", DeviceMeta{}) if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code { t.Fatalf("want code_invalid (anti-enumeration), got %v", apiErr) } @@ -115,7 +115,7 @@ func TestService_CodeWrong(t *testing.T) { const email = "wrong@example.com" _, _ = svc.SendCode(ctx, email, "") - _, apiErr := svc.Register(ctx, email, "000000", "password1") + _, apiErr := svc.Register(ctx, email, "000000", "password1", DeviceMeta{}) if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code { t.Fatalf("want code_invalid, got %v", apiErr) } @@ -131,7 +131,7 @@ func TestService_CodeExpired(t *testing.T) { // Expire the code key. svc.rdb.Del(ctx, codeKey(email)) - _, apiErr := svc.Register(ctx, email, code, "password1") + _, apiErr := svc.Register(ctx, email, code, "password1", DeviceMeta{}) if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code { t.Fatalf("want code_invalid after expiry, got %v", apiErr) } @@ -144,11 +144,11 @@ func TestService_CodeReuseRejected(t *testing.T) { _, _ = svc.SendCode(ctx, email, "") code := codeInRedis(t, svc, email) - if _, e := svc.Register(ctx, email, code, "password1"); e != nil { + if _, e := svc.Register(ctx, email, code, "password1", DeviceMeta{}); e != nil { t.Fatalf("first register: %v", e) } // Re-using the consumed code must fail. - _, apiErr := svc.Register(ctx, "other@example.com", code, "password1") + _, apiErr := svc.Register(ctx, "other@example.com", code, "password1", DeviceMeta{}) if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code { t.Fatalf("want code_invalid on reuse, got %v", apiErr) } @@ -163,12 +163,12 @@ func TestService_CodeBruteForceBurned(t *testing.T) { // 3 wrong attempts burn the code. for i := 0; i < 3; i++ { - if _, e := svc.Register(ctx, email, "999999", "password1"); e == nil { + if _, e := svc.Register(ctx, email, "999999", "password1", DeviceMeta{}); e == nil { t.Fatal("wrong code should fail") } } // Even the correct code no longer works. - if _, e := svc.Register(ctx, email, good, "password1"); e == nil || e.Code != ErrCodeInvalid.Code { + if _, e := svc.Register(ctx, email, good, "password1", DeviceMeta{}); e == nil || e.Code != ErrCodeInvalid.Code { t.Fatalf("burned code should reject correct value, got %v", e) } } @@ -205,25 +205,25 @@ func TestService_LoginAndLockout(t *testing.T) { const pw = "rightpassword" _, _ = svc.SendCode(ctx, email, "") - if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw); e != nil { + if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw, DeviceMeta{}); e != nil { t.Fatalf("register: %v", e) } // Correct login works. - pair, _, apiErr := svc.Login(ctx, email, pw, "") + pair, _, apiErr := svc.Login(ctx, email, pw, "", DeviceMeta{}) if apiErr != nil || pair == nil { t.Fatalf("login should succeed: %v", apiErr) } // 3 wrong attempts. for i := 0; i < 3; i++ { - _, _, e := svc.Login(ctx, email, "wrong", "") + _, _, e := svc.Login(ctx, email, "wrong", "", DeviceMeta{}) if e == nil || e.Code != ErrInvalidCredentials.Code { t.Fatalf("attempt %d want invalid_credentials, got %v", i, e) } } // Now locked, even with the correct password. - _, ra, e := svc.Login(ctx, email, pw, "") + _, ra, e := svc.Login(ctx, email, pw, "", DeviceMeta{}) if e == nil || e.Code != ErrAccountLocked.Code { t.Fatalf("want account_locked, got %v", e) } @@ -234,7 +234,7 @@ func TestService_LoginAndLockout(t *testing.T) { func TestService_LoginUnknownUser(t *testing.T) { svc, _, _ := newService(t, ServiceConfig{}) - _, _, apiErr := svc.Login(context.Background(), "ghost@example.com", "whatever", "") + _, _, apiErr := svc.Login(context.Background(), "ghost@example.com", "whatever", "", DeviceMeta{}) if apiErr == nil || apiErr.Code != ErrInvalidCredentials.Code { t.Fatalf("want invalid_credentials for unknown user, got %v", apiErr) } @@ -247,12 +247,12 @@ func TestService_BannedUserRejected(t *testing.T) { const pw = "password1" _, _ = svc.SendCode(ctx, email, "") - if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw); e != nil { + if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw, DeviceMeta{}); e != nil { t.Fatalf("register: %v", e) } store.setStatus(email, "banned") - _, _, apiErr := svc.Login(ctx, email, pw, "") + _, _, apiErr := svc.Login(ctx, email, pw, "", DeviceMeta{}) if apiErr == nil || apiErr.Code != ErrAccountBanned.Code { t.Fatalf("want account_banned, got %v", apiErr) } @@ -264,7 +264,7 @@ func TestService_RefreshRotation(t *testing.T) { const email = "refresh@example.com" _, _ = svc.SendCode(ctx, email, "") - pair, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1") + pair, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1", DeviceMeta{}) if e != nil { t.Fatalf("register: %v", e) } diff --git a/server/internal/auth/zz_tmpverify_test.go b/server/internal/auth/zz_tmpverify_test.go new file mode 100644 index 0000000..0a88415 --- /dev/null +++ b/server/internal/auth/zz_tmpverify_test.go @@ -0,0 +1,10 @@ +package auth +import "testing" +import "os" +func TestTmpVerify(t *testing.T){ + h,_ := os.ReadFile("/Users/wangjia/.claude/jobs/f76e813b/tmp/newhash.txt") + enc := string(h); enc = enc[:len(enc)-1] // strip newline + ok,err := VerifyPassword(enc, "wangjia812") + if err!=nil || !ok { t.Fatalf("verify failed ok=%v err=%v", ok, err) } + t.Log("verify OK") +} diff --git a/server/internal/devices/service.go b/server/internal/devices/service.go index ae4ed35..9b6d0eb 100644 --- a/server/internal/devices/service.go +++ b/server/internal/devices/service.go @@ -351,6 +351,8 @@ func normalizePlatform(s string) (string, bool) { return "windows", true case "macos": return "macos", true + case "linux": + return "linux", true } return "", false } diff --git a/server/internal/devices/service_test.go b/server/internal/devices/service_test.go index 95ebbcb..6f993f1 100644 --- a/server/internal/devices/service_test.go +++ b/server/internal/devices/service_test.go @@ -208,8 +208,8 @@ func TestRequirePaidTier(t *testing.T) { func TestNormalizePlatform(t *testing.T) { cases := map[string]bool{ - "ios": true, "iOS": true, "ANDROID": true, "windows": true, "macos": true, - "linux": false, "": false, "blackberry": false, + "ios": true, "iOS": true, "ANDROID": true, "windows": true, "macos": true, "linux": true, + "": false, "blackberry": false, } for in, wantOK := range cases { _, ok := normalizePlatform(in) diff --git a/todo/todo.html b/todo/todo.html index 1830757..87ce5ae 100644 --- a/todo/todo.html +++ b/todo/todo.html @@ -257,8 +257,8 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
12全部
9待开始
0开发中
-
3待验收
-
0已验收
+
1待验收
+
2已验收
@@ -583,7 +583,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
- 🔍 待验收 3 + 🔍 待验收 1 ▴ 收起
@@ -617,50 +617,31 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
- -
  • -
    - SendCode 发信失败记日志 + 测试 -
    - 待验收 - 重要 - 三级 - - -
    -
    - -
    auth/service.go 异步发信把错误静默吞了(:157 SendCode 的 _=s.mailer.SendCode(...),:133 SendAlreadyRegistered 同样),导致 SMTP 故障日志无痕、本次 587 被封难排查。改为发信失败时记日志(slog.Error,脱敏:只记 send failed:<err>,绝不记验证码,邮箱可打码,守 no-secret-in-logs)。并加测试:注入会失败的 mock Mailer,断言发送失败时确实记了一条错误日志(可用 LogMailer 思路/捕获 slog handler 验证)。
    - - - -
  • - -
  • +
    +
    + ✅ 已验收 2 + ▾ 展开 +
    +
  • - + +
  • +
    + SendCode 发信失败记日志 + 测试 +
    + 已验收 + 重要 + 三级 + + +
    +
    + +
    auth/service.go 异步发信把错误静默吞了(:157 SendCode 的 _=s.mailer.SendCode(...),:133 SendAlreadyRegistered 同样),导致 SMTP 故障日志无痕、本次 587 被封难排查。改为发信失败时记日志(slog.Error,脱敏:只记 send failed:<err>,绝不记验证码,邮箱可打码,守 no-secret-in-logs)。并加测试:注入会失败的 mock Mailer,断言发送失败时确实记了一条错误日志(可用 LogMailer 思路/捕获 slog handler 验证)。
    + + + -
    -
    - ✅ 已验收 0 - ▾ 展开 -
    - +
  • diff --git a/todo/todo.json b/todo/todo.json index a706f2e..69047a1 100644 --- a/todo/todo.json +++ b/todo/todo.json @@ -1,7 +1,7 @@ { "meta": { "title": "feature+windows — 项目 TODO", - "updated_at": "2026-06-28T11:27:56.709Z" + "updated_at": "2026-06-28T13:19:24.300Z" }, "seq": 12, "items": [ @@ -75,10 +75,10 @@ "tags": [ "后端" ], - "status": "done", + "status": "accepted", "created_at": "2026-06-27T23:37:24.980Z", - "done": false, - "completed_at": null, + "done": true, + "completed_at": "2026-06-28T13:19:24.207Z", "version": null }, { @@ -91,10 +91,10 @@ "前端", "后端" ], - "status": "done", + "status": "accepted", "created_at": "2026-06-28T00:21:30.990Z", - "done": false, - "completed_at": null, + "done": true, + "completed_at": "2026-06-28T13:19:24.299Z", "version": null }, {