diff --git a/backend/cmd/migrate-sqlite-postgres/main.go b/backend/cmd/migrate-sqlite-postgres/main.go index 1f1e4a82a..405be0b6a 100644 --- a/backend/cmd/migrate-sqlite-postgres/main.go +++ b/backend/cmd/migrate-sqlite-postgres/main.go @@ -370,7 +370,9 @@ func migrations() []tableMigration { migrateTable[model.Tool]("tools"), migrateTable[model.ToolFavorite]("tool_favorites"), migrateTable[model.User]("users"), + migrateTable[model.UserAvatar]("user_avatars"), migrateTable[model.AuthSession]("auth_sessions"), + migrateTable[model.LoginLog]("login_logs"), migrateTable[model.UserIdentity]("user_identities"), migrateTable[model.OAuthState]("o_auth_states"), migrateTable[model.EmailVerificationCode]("email_verification_codes"), diff --git a/backend/internal/app/admin_login_logs.go b/backend/internal/app/admin_login_logs.go new file mode 100644 index 000000000..00fd5ab00 --- /dev/null +++ b/backend/internal/app/admin_login_logs.go @@ -0,0 +1,87 @@ +package app + +import ( + "errors" + "net" + "strings" + "time" + "yingce/backend/internal/kernel" + "yingce/backend/internal/model" + "yingce/backend/internal/repository" + + "gorm.io/gorm" +) + +type LoginLogQuery struct{ Keyword, Event, Method, Result, IP, From, To string } +type LoginLogPage struct { + Logs []model.LoginLog `json:"logs"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +func (s *Service) AdminLoginLogs(actor *model.User, query LoginLogQuery, page, pageSize int) (*LoginLogPage, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + filter := repository.LoginLogFilter{Keyword: strings.TrimSpace(query.Keyword), Event: strings.TrimSpace(query.Event), Method: strings.TrimSpace(query.Method), Result: strings.TrimSpace(query.Result), IP: strings.TrimSpace(query.IP)} + for _, enum := range []struct { + value string + options []string + }{ + {filter.Event, []string{"", "login", "logout"}}, + {filter.Result, []string{"", "success", "failed"}}, + {filter.Method, []string{"", "password", "email", "sms", "linuxdo", "verification", "unknown"}}, + } { + valid := false + for _, option := range enum.options { + if option == enum.value { + valid = true + break + } + } + if !valid { + return nil, kernel.BadAuthRequest("登录日志筛选参数无效") + } + } + if len([]rune(filter.Keyword)) > 160 || (filter.IP != "" && net.ParseIP(filter.IP) == nil) { + return nil, kernel.BadAuthRequest("关键词或 IP 参数无效") + } + for _, bound := range []struct { + raw string + value **time.Time + }{{query.From, &filter.From}, {query.To, &filter.To}} { + if bound.raw == "" { + continue + } + parsed, err := time.Parse(time.RFC3339, bound.raw) + if err != nil { + return nil, kernel.BadAuthRequest("时间必须使用 RFC3339 格式") + } + *bound.value = &parsed + } + if filter.From != nil && filter.To != nil && filter.From.After(*filter.To) { + return nil, kernel.BadAuthRequest("开始时间不能晚于结束时间") + } + page, pageSize = normalizeAdminPage(page, pageSize) + logs, total, err := s.repo.LoginLogs(filter, pageSize, (page-1)*pageSize) + if err != nil { + return nil, err + } + return &LoginLogPage{Logs: logs, Total: total, Page: page, PageSize: pageSize}, nil +} + +func (s *Service) AdminLoginLog(actor *model.User, id string) (*model.LoginLog, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + event, err := s.repo.LoginLog(strings.TrimSpace(id)) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, kernel.NotFound("登录日志不存在") + } + return event, err +} + +func (s *Service) RecordRejectedLogin(method string, context LoginContext, status int) error { + return s.authDomain().RecordRejectedLogin(method, context, status) +} diff --git a/backend/internal/app/auth_bridge.go b/backend/internal/app/auth_bridge.go index 6aea2324e..556b4a98a 100644 --- a/backend/internal/app/auth_bridge.go +++ b/backend/internal/app/auth_bridge.go @@ -14,6 +14,7 @@ type ( EmailCodeCooldownError = auth.EmailCodeCooldownError RegisterRequest = auth.RegisterRequest LoginRequest = auth.LoginRequest + LoginContext = auth.LoginContext PublicAuthSettings = auth.PublicAuthSettings AuthSessionResult = auth.AuthSessionResult AuthUser = auth.AuthUser @@ -138,8 +139,8 @@ func (s *Service) LoginRateLimitSubject(account string) (string, error) { return s.authDomain().LoginRateLimitSubject(account) } -func (s *Service) Logout(cookieValue string) error { - return s.authDomain().Logout(cookieValue) +func (s *Service) Logout(cookieValue string, contexts ...LoginContext) error { + return s.authDomain().Logout(cookieValue, contexts...) } func (s *Service) CurrentUser(cookieValue string) (*model.User, error) { @@ -206,8 +207,8 @@ func (s *Service) BeginLinuxDOLogin(nextPath string, acceptedTerms bool) (string return s.authDomain().BeginLinuxDOLogin(nextPath, acceptedTerms) } -func (s *Service) CompleteLinuxDOLogin(stateValue string, code string) (*LinuxDOCallbackResult, error) { - return s.authDomain().CompleteLinuxDOLogin(stateValue, code) +func (s *Service) CompleteLinuxDOLogin(stateValue string, code string, cookieValue string, contexts ...LoginContext) (*LinuxDOCallbackResult, error) { + return s.authDomain().CompleteLinuxDOLogin(stateValue, code, cookieValue, contexts...) } func (s *Service) AdminLibTVSetting(actor *model.User) (*PublicLibTVSetting, error) { diff --git a/backend/internal/app/auth_profile.go b/backend/internal/app/auth_profile.go new file mode 100644 index 000000000..c19b25e97 --- /dev/null +++ b/backend/internal/app/auth_profile.go @@ -0,0 +1,33 @@ +package app + +import ( + "context" + "yingce/backend/internal/auth" + "yingce/backend/internal/model" +) + +type UserProfile = auth.UserProfile +type ProfilePasswordRequest = auth.ProfilePasswordRequest +type ProfileEmailCodeRequest = auth.ProfileEmailCodeRequest +type ProfileEmailChangeRequest = auth.ProfileEmailChangeRequest +type ProfileEmailChangeResult = auth.ProfileEmailChangeResult +type VerificationTicket = auth.VerificationTicket + +func (s *Service) UserProfile(actor *model.User, cookie string) (*UserProfile, error) { + return s.authDomain().UserProfile(actor, cookie) +} +func (s *Service) ChangeProfilePassword(actor *model.User, cookie string, req ProfilePasswordRequest) error { + return s.authDomain().ChangeProfilePassword(actor, cookie, req) +} +func (s *Service) SendProfileEmailCode(ctx context.Context, actor *model.User, cookie string, req ProfileEmailCodeRequest) (*VerificationTicket, error) { + return s.authDomain().SendProfileEmailCode(ctx, actor, cookie, req) +} +func (s *Service) ChangeProfileEmail(actor *model.User, cookie string, req ProfileEmailChangeRequest) (*ProfileEmailChangeResult, error) { + return s.authDomain().ChangeProfileEmail(actor, cookie, req) +} +func (s *Service) BeginLinuxDOReauthentication(actor *model.User, cookie string) (string, error) { + return s.authDomain().BeginLinuxDOReauthentication(actor, cookie) +} +func (s *Service) LinuxDOReauthenticationState(stateValue string) (bool, error) { + return s.authDomain().LinuxDOReauthenticationState(stateValue) +} diff --git a/backend/internal/app/profile_avatar.go b/backend/internal/app/profile_avatar.go new file mode 100644 index 000000000..236cdd375 --- /dev/null +++ b/backend/internal/app/profile_avatar.go @@ -0,0 +1,23 @@ +package app + +import ( + "mime/multipart" + + "yingce/backend/internal/model" +) + +func (s *Service) UpdateProfileAvatar(actor *model.User, header *multipart.FileHeader) (*AuthUser, error) { + return s.authDomain().UpdateProfileAvatar(actor, header) +} + +func (s *Service) UseDefaultProfileAvatar(actor *model.User) (*AuthUser, error) { + return s.authDomain().UseDefaultProfileAvatar(actor) +} + +func (s *Service) SelectDefaultProfileAvatar(actor *model.User, preset string) (*AuthUser, error) { + return s.authDomain().SelectDefaultProfileAvatar(actor, preset) +} + +func (s *Service) ProfileAvatarContent(actor *model.User, targetUserID string) ([]byte, error) { + return s.authDomain().ProfileAvatarContent(actor, targetUserID) +} diff --git a/backend/internal/auth/account.go b/backend/internal/auth/account.go new file mode 100644 index 000000000..331d732fb --- /dev/null +++ b/backend/internal/auth/account.go @@ -0,0 +1,253 @@ +package auth + +import ( + "context" + "crypto/hmac" + "errors" + "strings" + "time" + + "gorm.io/gorm" + "yingce/backend/internal/kernel" + "yingce/backend/internal/model" + "yingce/backend/internal/repository" +) + +type UserProfile struct { + User AuthUser `json:"user"` + HasPassword bool `json:"hasPassword"` + EmailEnabled bool `json:"emailEnabled"` + LinuxDOEnabled bool `json:"linuxdoEnabled"` + ReauthenticatedUntil *time.Time `json:"reauthenticatedUntil,omitempty"` +} +type ProfilePasswordRequest struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` +} +type ProfileEmailCodeRequest struct { + Email string `json:"email"` + CurrentPassword string `json:"currentPassword"` +} +type ProfileEmailChangeRequest struct { + Ticket string `json:"ticket"` + EmailCode string `json:"emailCode"` + CurrentPassword string `json:"currentPassword"` +} +type ProfileEmailChangeResult struct { + Changed bool `json:"changed"` + NotificationStatus string `json:"notificationStatus"` +} + +func (s *Service) profileSession(actor *model.User, cookie string) (*model.User, *model.AuthSession, error) { + if actor == nil || actor.ID == "" { + return nil, nil, kernel.Unauthorized("请先登录") + } + id, token := parseSessionCookie(cookie) + if id == "" || token == "" { + return nil, nil, kernel.Unauthorized("登录状态已失效") + } + session, err := s.repo.AuthSession(id) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, kernel.Unauthorized("登录状态已失效") + } + if err != nil { + return nil, nil, err + } + if session.UserID != actor.ID || !session.ExpiresAt.After(time.Now()) || !hmac.Equal([]byte(session.TokenHash), []byte(HashToken(token))) { + return nil, nil, kernel.Unauthorized("登录状态已失效") + } + user, err := s.repo.User(actor.ID) + if err != nil { + return nil, nil, err + } + if user.Status != model.UserStatusActive { + return nil, nil, kernel.Forbidden("该账号已被禁用") + } + return user, session, nil +} + +func authorizeProfileChange(user *model.User, session *model.AuthSession, password string) error { + if user.PasswordHash != "" { + if !verifyPassword(password, user.PasswordHash) { + return kernel.Unauthorized("当前密码不正确") + } + return nil + } + now := time.Now() + if session.ReauthenticatedAt == nil || session.ReauthenticatedAt.After(now) || !session.ReauthenticatedAt.Add(repository.ProfileReauthenticationTTL).After(now) { + return kernel.Unauthorized("请先通过 Linux.do 重新验证身份") + } + return nil +} + +func profileWriteError(err error) error { + if errors.Is(err, repository.ErrProfileAuthorization) { + return kernel.Unauthorized("账号或身份验证状态已变化,请重新登录或验证") + } + if errors.Is(err, repository.ErrVerificationInvalid) { + return invalidVerification() + } + return err +} + +func (s *Service) UserProfile(actor *model.User, cookie string) (*UserProfile, error) { + user, session, err := s.profileSession(actor, cookie) + if err != nil { + return nil, err + } + public, err := s.PublicAuthUser(user) + if err != nil { + return nil, err + } + emailEnabled, err := s.EmailEnabled() + if err != nil { + return nil, err + } + _, linuxdo, err := s.readLinuxDOSetting() + if err != nil { + return nil, err + } + result := &UserProfile{User: public, HasPassword: user.PasswordHash != "", EmailEnabled: emailEnabled, LinuxDOEnabled: linuxdo.Enabled} + now := time.Now() + if session.ReauthenticatedAt != nil && !session.ReauthenticatedAt.After(now) { + until := session.ReauthenticatedAt.Add(repository.ProfileReauthenticationTTL) + if until.After(now) { + result.ReauthenticatedUntil = &until + } + } + return result, nil +} + +func (s *Service) ChangeProfilePassword(actor *model.User, cookie string, req ProfilePasswordRequest) error { + if err := ValidatePassword(req.NewPassword); err != nil { + return err + } + if len([]byte(req.NewPassword)) > 72 { + return kernel.BadAuthRequest("密码的 UTF-8 长度不能超过 72 字节") + } + user, session, err := s.profileSession(actor, cookie) + if err != nil { + return err + } + if err := authorizeProfileChange(user, session, req.CurrentPassword); err != nil { + return err + } + hash, err := HashPassword(req.NewPassword) + if err != nil { + return err + } + return profileWriteError(s.repo.ChangeProfilePassword(user, session, hash, time.Now())) +} + +func (s *Service) profileEmailSetting() (EmailSettingValue, error) { + _, setting, err := s.readEmailSetting() + if err != nil { + return setting, err + } + if !setting.Enabled || setting.Host == "" || setting.Port < 1 || setting.FromEmail == "" { + return setting, kernel.Forbidden("邮件服务未启用,暂时无法更换邮箱") + } + return resolveEmailSender(setting, s.host.BrandName()), nil +} + +func (s *Service) SendProfileEmailCode(ctx context.Context, actor *model.User, cookie string, req ProfileEmailCodeRequest) (*VerificationTicket, error) { + user, session, err := s.profileSession(actor, cookie) + if err != nil { + return nil, err + } + if err := authorizeProfileChange(user, session, req.CurrentPassword); err != nil { + return nil, err + } + setting, err := s.profileEmailSetting() + if err != nil { + return nil, err + } + email := NormalizeEmail(req.Email) + if err := ValidateEmail(email); err != nil { + return nil, err + } + if email == NormalizeEmail(user.Email) { + return nil, kernel.BadAuthRequest("请输入与当前邮箱不同的新邮箱") + } + if err := s.repo.CheckEmailAvailable(email, user.ID); err != nil { + return nil, err + } + code, err := randomNumericCode(6) + if err != nil { + return nil, err + } + secret := RandomToken() + now := time.Now() + v := &model.AuthVerification{ID: kernel.NewID(), TokenHash: HashToken(secret), Purpose: "profile_email", Method: "email", UserID: user.ID, SessionID: session.ID, CredentialHash: repository.ProfileCredentialHash(user.PasswordHash), Email: email, CreatedAt: now, ExpiresAt: now.Add(10 * time.Minute)} + v.EmailHash, err = s.verificationHash(v.ID + ":email:" + email + ":" + code) + if err != nil { + return nil, err + } + deliveryHash, err := s.verificationHash("delivery:" + email) + if err != nil { + return nil, err + } + limits := []repository.NotificationLimit{{Key: "auth-day:" + deliveryHash + ":" + now.UTC().Format("2006-01-02"), Limit: 20, Cooldown: time.Minute, ExpiresAt: now.Add(48 * time.Hour)}, {Key: "auth-hour:" + deliveryHash + ":" + now.UTC().Format("2006-01-02T15"), Limit: 5, ExpiresAt: now.Add(2 * time.Hour)}} + if err := s.repo.CreateAuthVerification(v, limits); err != nil { + if errors.Is(err, repository.ErrNotificationLimit) { + return nil, kernel.RateLimited("发送过于频繁,请至少等待 60 秒后重试(每小时最多 5 次)") + } + return nil, err + } + if err := s.deliverEmail(setting, email, setting.FromName+"更换邮箱验证码", "验证码:"+code+"\n\n验证码 10 分钟内有效,仅用于更换本账号的邮箱。若非本人操作,请忽略本邮件。"); err != nil { + return nil, kernel.NewAppError(503, "邮件发送失败,请稍后重试") + } + if err := s.repo.SetAuthVerificationReady(v.ID); err != nil { + return nil, err + } + return &VerificationTicket{Ticket: v.ID + "." + secret, ExpiresIn: 600, RetryAfter: 60}, nil +} + +func (s *Service) ChangeProfileEmail(actor *model.User, cookie string, req ProfileEmailChangeRequest) (*ProfileEmailChangeResult, error) { + user, session, err := s.profileSession(actor, cookie) + if err != nil { + return nil, err + } + if err := authorizeProfileChange(user, session, req.CurrentPassword); err != nil { + return nil, err + } + setting, err := s.profileEmailSetting() + if err != nil { + return nil, err + } + id, secret, ok := strings.Cut(req.Ticket, ".") + if !ok || id == "" || len(id) > 36 || len(secret) != 64 { + return nil, invalidVerification() + } + v, err := s.repo.AuthVerification(id) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, invalidVerification() + } + if err != nil { + return nil, err + } + if v.Purpose != "profile_email" || v.Method != "email" || v.UserID != user.ID || v.SessionID != session.ID || v.CredentialHash != repository.ProfileCredentialHash(user.PasswordHash) || !hmac.Equal([]byte(v.TokenHash), []byte(HashToken(secret))) { + return nil, invalidVerification() + } + if err := s.repo.AttemptAuthVerification(v.ID, time.Now()); err != nil { + return nil, profileWriteError(err) + } + hash, err := s.verificationHash(v.ID + ":email:" + v.Email + ":" + strings.TrimSpace(req.EmailCode)) + if err != nil { + return nil, err + } + if !hmac.Equal([]byte(hash), []byte(v.EmailHash)) { + return nil, invalidVerification() + } + if err := s.repo.ChangeProfileEmail(user, session, v, time.Now()); err != nil { + return nil, profileWriteError(err) + } + result := &ProfileEmailChangeResult{Changed: true, NotificationStatus: "not_needed"} + if user.Email != "" { + result.NotificationStatus = "sent" + if err := s.deliverEmail(setting, user.Email, setting.FromName+"账号邮箱已变更", "你的"+setting.FromName+"账号邮箱已成功变更,所有登录会话已退出。若非本人操作,请立即联系管理员。"); err != nil { + result.NotificationStatus = "failed" + } + } + return result, nil +} diff --git a/backend/internal/auth/account_test.go b/backend/internal/auth/account_test.go new file mode 100644 index 000000000..7257f2ede --- /dev/null +++ b/backend/internal/auth/account_test.go @@ -0,0 +1,231 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "gorm.io/gorm" + "yingce/backend/internal/model" +) + +type profileTestHost struct{ nopHost } + +func (profileTestHost) SettingsEncryptionKey() ([]byte, error) { + return []byte("profile-test-key-32-bytes-long!!!"), nil +} + +func newProfileTestService(t *testing.T) (*Service, *gorm.DB, *model.User, string) { + t.Helper() + s, db := newPasswordResetTestService(t) + s.host = profileTestHost{} + if err := db.AutoMigrate(&model.AuthVerification{}, &model.NotificationQuota{}, &model.UserIdentity{}, &model.OAuthState{}); err != nil { + t.Fatal(err) + } + hash, err := HashPassword("old-password") + if err != nil { + t.Fatal(err) + } + u := &model.User{ID: "profile-user", Username: "profile-user", Email: "old@example.com", Status: model.UserStatusActive, PasswordHash: hash} + if err := db.Create(u).Error; err != nil { + t.Fatal(err) + } + for _, id := range []string{"session-one", "session-two"} { + if err := db.Create(&model.AuthSession{ID: id, UserID: u.ID, TokenHash: HashToken("token"), ExpiresAt: time.Now().Add(time.Hour)}).Error; err != nil { + t.Fatal(err) + } + } + return s, db, u, "session-one.token" +} + +func TestProfilePasswordRequiresCurrentCredentialsAndRevokesAllSessions(t *testing.T) { + s, db, u, cookie := newProfileTestService(t) + if err := s.ChangeProfilePassword(u, cookie, ProfilePasswordRequest{CurrentPassword: "wrong", NewPassword: "new-password"}); err == nil { + t.Fatal("wrong password accepted") + } + if err := s.ChangeProfilePassword(u, cookie, ProfilePasswordRequest{CurrentPassword: "old-password", NewPassword: "new-password"}); err != nil { + t.Fatal(err) + } + var updated model.User + db.First(&updated, "id = ?", u.ID) + if !verifyPassword("new-password", updated.PasswordHash) { + t.Fatal("password not changed") + } + var count int64 + db.Model(&model.AuthSession{}).Where("user_id = ?", u.ID).Count(&count) + if count != 0 { + t.Fatal("sessions not revoked") + } + if err := s.ChangeProfilePassword(u, cookie, ProfilePasswordRequest{CurrentPassword: "old-password", NewPassword: "third-password"}); err == nil { + t.Fatal("stale actor/session accepted") + } +} + +func TestProfileOAuthAccountNeedsRecentSameSessionReauthentication(t *testing.T) { + s, db, u, cookie := newProfileTestService(t) + db.Model(u).Update("password_hash", "") + u.PasswordHash = "" + if err := s.ChangeProfilePassword(u, cookie, ProfilePasswordRequest{NewPassword: "new-password"}); err == nil { + t.Fatal("OAuth account accepted without reauthentication") + } + now := time.Now() + db.Model(&model.AuthSession{}).Where("id = ?", "session-two").Update("reauthenticated_at", now) + if err := s.ChangeProfilePassword(u, cookie, ProfilePasswordRequest{NewPassword: "new-password"}); err == nil { + t.Fatal("another session reauthentication accepted") + } + expired := now.Add(-6 * time.Minute) + db.Model(&model.AuthSession{}).Where("id = ?", "session-one").Update("reauthenticated_at", expired) + if err := s.ChangeProfilePassword(u, cookie, ProfilePasswordRequest{NewPassword: "new-password"}); err == nil { + t.Fatal("expired reauthentication accepted") + } + db.Model(&model.AuthSession{}).Where("id = ?", "session-one").Update("reauthenticated_at", now) + if err := s.ChangeProfilePassword(u, cookie, ProfilePasswordRequest{NewPassword: "new-password"}); err != nil { + t.Fatal(err) + } +} + +func TestProfileEmailTicketBoundToSessionAndReportsOldMailFailure(t *testing.T) { + s, db, u, cookie := newProfileTestService(t) + var code string + s.SetMailSender(func(_ EmailSettingValue, to, subject, body string) error { + if to == "new@external.example" { + code = codeFromEmailBody(body) + return nil + } + return errors.New("mail unavailable") + }) + ticket, err := s.SendProfileEmailCode(context.Background(), u, cookie, ProfileEmailCodeRequest{Email: "new@external.example", CurrentPassword: "old-password"}) + if err != nil { + t.Fatal(err) + } + if len(code) != 6 { + t.Fatal("missing code") + } + req := ProfileEmailChangeRequest{Ticket: ticket.Ticket, EmailCode: code, CurrentPassword: "old-password"} + if _, err := s.ChangeProfileEmail(u, "session-two.token", req); err == nil { + t.Fatal("another session used ticket") + } + result, err := s.ChangeProfileEmail(u, cookie, req) + if err != nil { + t.Fatal(err) + } + if !result.Changed || result.NotificationStatus != "failed" { + t.Fatalf("result %#v", result) + } + var updated model.User + db.First(&updated, "id = ?", u.ID) + if updated.Email != "new@external.example" || updated.EmailVerifiedAt == nil { + t.Fatalf("updated user %#v", updated) + } + var count int64 + db.Model(&model.AuthSession{}).Where("user_id = ?", u.ID).Count(&count) + if count != 0 { + t.Fatal("sessions remain") + } + if _, err := s.ChangeProfileEmail(u, cookie, req); err == nil { + t.Fatal("reused ticket accepted") + } +} + +func TestProfileEmailDisabledAndDuplicateAreRejected(t *testing.T) { + s, db, u, cookie := newProfileTestService(t) + db.Create(&model.User{ID: "other", Username: "other", Email: "used@example.com", Status: model.UserStatusActive}) + if _, err := s.SendProfileEmailCode(context.Background(), u, cookie, ProfileEmailCodeRequest{Email: "used@example.com", CurrentPassword: "old-password"}); err == nil { + t.Fatal("duplicate email accepted") + } + db.Model(&model.SystemSetting{}).Where("key = ?", emailSettingKey).Update("value_json", `{"enabled":false}`) + if _, err := s.SendProfileEmailCode(context.Background(), u, cookie, ProfileEmailCodeRequest{Email: "new@example.com", CurrentPassword: "old-password"}); err == nil { + t.Fatal("disabled SMTP accepted") + } +} + +func TestProfileLinuxDOReauthenticationRequiresMatchingSubjectAndSession(t *testing.T) { + for _, mode := range []string{"matching", "wrong-subject", "wrong-session", "expired-session"} { + t.Run(mode, func(t *testing.T) { + s, db, u, cookie := newProfileTestService(t) + subject := "linked-subject" + if mode == "wrong-subject" { + subject = "another-person" + } + t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1") + upstreamCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls++ + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/token" { + w.Write([]byte(`{"access_token":"test-access-token"}`)) + return + } + json.NewEncoder(w).Encode(map[string]string{"id": subject}) + })) + defer server.Close() + setting, err := json.Marshal(linuxDOSettingValue{Enabled: true, ClientID: "test", AuthorizationURL: server.URL + "/authorize", TokenURL: server.URL + "/token", UserInfoURL: server.URL + "/userinfo", RedirectURL: "http://localhost/callback"}) + if err != nil { + t.Fatal(err) + } + if err := db.Create(&model.SystemSetting{Key: linuxDOSettingKey, ValueJSON: string(setting)}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.UserIdentity{ID: "identity", UserID: u.ID, Provider: "linuxdo", Subject: "linked-subject"}).Error; err != nil { + t.Fatal(err) + } + location, err := s.BeginLinuxDOReauthentication(u, cookie) + if err != nil { + t.Fatal(err) + } + parsed, err := url.Parse(location) + if err != nil { + t.Fatal(err) + } + state := parsed.Query().Get("state") + isReauth, err := s.LinuxDOReauthenticationState(state) + if err != nil || !isReauth { + t.Fatalf("state marker %v %v", isReauth, err) + } + if mode == "wrong-session" { + cookie = "session-two.token" + } + if mode == "expired-session" { + db.Model(&model.AuthSession{}).Where("id = ?", "session-one").Update("expires_at", time.Now().Add(-time.Minute)) + } + result, err := s.CompleteLinuxDOLogin(state, "test-code", cookie) + if mode == "matching" { + if err != nil { + t.Fatal(err) + } + if !result.Reauthenticated || result.Session != nil || result.Next != "/settings?section=profile&reauth=success" { + t.Fatalf("result %#v", result) + } + } else if err == nil { + t.Fatal("invalid reauthentication accepted") + } + if (mode == "wrong-session" || mode == "expired-session") && upstreamCalls != 0 { + t.Fatal("invalid session contacted provider") + } + var session model.AuthSession + if err := db.First(&session, "id = ?", "session-one").Error; err != nil { + t.Fatal(err) + } + if (session.ReauthenticatedAt != nil) != (mode == "matching") { + t.Fatal("incorrect reauthentication marker") + } + var count int64 + db.Model(&model.AuthSession{}).Count(&count) + if count != 2 { + t.Fatal("reauthentication created a session") + } + db.Model(&model.User{}).Count(&count) + if count != 1 { + t.Fatal("reauthentication created a user") + } + if _, err := s.CompleteLinuxDOLogin(state, "test-code", cookie); err == nil { + t.Fatal("OAuth state reused") + } + }) + } +} diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index 00b33aa3f..d7fa1e08d 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -29,20 +29,22 @@ var usernamePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{3,32}$`) type AuthError = kernel.AppError type RegisterRequest struct { - Username string `json:"username"` - Email string `json:"email"` - EmailCode string `json:"emailCode"` - Phone string `json:"phone"` - SMSCode string `json:"smsCode"` - Ticket string `json:"ticket"` - DisplayName string `json:"displayName"` - Password string `json:"password"` - AcceptedTerms bool `json:"acceptedTerms"` + LoginContext LoginContext `json:"-"` + Username string `json:"username"` + Email string `json:"email"` + EmailCode string `json:"emailCode"` + Phone string `json:"phone"` + SMSCode string `json:"smsCode"` + Ticket string `json:"ticket"` + DisplayName string `json:"displayName"` + Password string `json:"password"` + AcceptedTerms bool `json:"acceptedTerms"` } type LoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` + LoginContext LoginContext `json:"-"` + Username string `json:"username"` + Password string `json:"password"` } type PublicAuthSettings struct { @@ -247,7 +249,12 @@ func (s *Service) Register(req RegisterRequest) (*AuthSessionResult, error) { if err := s.host.EnsureSignupBonus(user.ID); err != nil { return nil, err } - return s.createAuthSession(&user) + event := NewLoginLog("password", "registration", req.LoginContext) + result, err := s.createAuthSession(&user, event) + if err != nil { + return nil, s.recordLoginFailure(event, err) + } + return result, nil } // LoginRateLimitSubject uses the same account lookup as password authentication. @@ -263,7 +270,14 @@ func (s *Service) LoginRateLimitSubject(account string) (string, error) { return "user:" + user.ID, nil } -func (s *Service) Login(req LoginRequest) (*AuthSessionResult, error) { +func (s *Service) Login(req LoginRequest) (result *AuthSessionResult, err error) { + event := NewLoginLog("password", "login", req.LoginContext) + event.Account = MaskLoginAccount(req.Username) + defer func() { + if err != nil { + err = s.recordLoginFailure(event, err) + } + }() account := strings.TrimSpace(req.Username) user, err := s.repo.UserByAccount(account) if err != nil { @@ -272,6 +286,7 @@ func (s *Service) Login(req LoginRequest) (*AuthSessionResult, error) { } return nil, err } + loginLogUser(event, user) if user.Status != model.UserStatusActive { return nil, kernel.Forbidden("该账号已被禁用") } @@ -287,16 +302,27 @@ func (s *Service) Login(req LoginRequest) (*AuthSessionResult, error) { if err := s.host.EnsureSignupBonus(user.ID); err != nil { return nil, err } - s.host.RecordActivity(user.ID, "login", 1) - return s.createAuthSession(user) + return s.createAuthSession(user, event) } -func (s *Service) Logout(cookieValue string) error { - sessionID, _ := parseSessionCookie(cookieValue) - if sessionID == "" { +func (s *Service) Logout(cookieValue string, contexts ...LoginContext) (err error) { + sessionID, token := parseSessionCookie(cookieValue) + if sessionID == "" || token == "" { return nil } - return s.repo.DeleteAuthSession(sessionID) + var context LoginContext + if len(contexts) > 0 { + context = contexts[0] + } + event := NewLoginLog("unknown", "logout", context) + event.Event = "logout" + event.SessionRef = HashToken(sessionID) + defer func() { + if err != nil { + err = s.recordLoginFailure(event, err) + } + }() + return s.repo.RevokeAuthSessionWithLog(sessionID, HashToken(token), event) } func (s *Service) CurrentUser(cookieValue string) (*model.User, error) { @@ -330,6 +356,9 @@ func (s *Service) CurrentUser(cookieValue string) (*model.User, error) { // 认证响应只补充当前用户自己的第三方公开身份,不把身份表或密钥字段暴露给其他列表接口。 func (s *Service) PublicAuthUser(user *model.User) (AuthUser, error) { result := AuthUser{User: *user} + if user.AvatarMode == "custom" { + result.AvatarURL = profileAvatarURL(user) + } identity, err := s.repo.UserIdentityForUser(user.ID, "linuxdo") if errors.Is(err, gorm.ErrRecordNotFound) { return result, nil @@ -337,14 +366,17 @@ func (s *Service) PublicAuthUser(user *model.User) (AuthUser, error) { if err != nil { return AuthUser{}, err } - result.AvatarURL = identity.AvatarURL + if user.AvatarMode == "" { + result.AvatarURL = identity.AvatarURL + } result.IdentityProvider = identity.Provider result.IdentityID = identity.Subject result.IdentityUsername = identity.ProviderUsername return result, nil } -func (s *Service) createAuthSession(user *model.User) (*AuthSessionResult, error) { +func (s *Service) createAuthSession(user *model.User, event *model.LoginLog) (*AuthSessionResult, error) { + loginLogUser(event, user) publicUser, err := s.PublicAuthUser(user) if err != nil { return nil, err @@ -359,9 +391,12 @@ func (s *Service) createAuthSession(user *model.User) (*AuthSessionResult, error CreatedAt: now, UpdatedAt: now, } - if err := s.repo.Create(&session); err != nil { - return nil, err + event.SessionRef = HashToken(session.ID) + event.CreatedAt = now.UTC() + if err := s.repo.CreateAuthSessionForUser(user, &session, event); err != nil { + return nil, profileWriteError(err) } + s.host.RecordActivity(user.ID, "login", 1) return &AuthSessionResult{User: publicUser, Session: session.ID + "." + token, MaxAgeSecs: int(sessionMaxAge.Seconds())}, nil } diff --git a/backend/internal/auth/auth_registration_test.go b/backend/internal/auth/auth_registration_test.go index 76a94ff7f..2ac78a87d 100644 --- a/backend/internal/auth/auth_registration_test.go +++ b/backend/internal/auth/auth_registration_test.go @@ -158,7 +158,23 @@ func TestLinuxDORegistrationAgreement(t *testing.T) { if err := db.Where("state_hash = ?", HashToken(stateValue)).First(&saved).Error; err != nil || saved.AcceptedTerms != tc.accepted { t.Fatalf("saved consent = %v, err=%v", saved.AcceptedTerms, err) } - result, err := svc.CompleteLinuxDOLogin(stateValue, "test-code") + result, err := svc.CompleteLinuxDOLogin(stateValue, "test-code", "") + var event model.LoginLog + if logErr := db.First(&event).Error; logErr != nil { + t.Fatal(logErr) + } + if event.Method != "linuxdo" || event.Event != "login" { + t.Fatalf("OAuth log: %#v", event) + } + if tc.wantError == "" && event.Result != "success" { + t.Fatalf("OAuth success log: %#v", event) + } + if tc.wantError != "" && event.Result != "failed" { + t.Fatalf("OAuth failure log: %#v", event) + } + if !tc.existing && tc.wantError == "" && event.Source != "registration" { + t.Fatalf("OAuth registration source: %q", event.Source) + } wantUsers, wantRecords := int64(2), int64(1) if tc.wantError != "" { var authErr *AuthError @@ -178,7 +194,7 @@ func TestLinuxDORegistrationAgreement(t *testing.T) { t.Fatalf("%T count=%d want=%d err=%v", entity, count, wantRecords, err) } } - if _, err := svc.CompleteLinuxDOLogin(stateValue, "test-code"); err == nil { + if _, err := svc.CompleteLinuxDOLogin(stateValue, "test-code", ""); err == nil { t.Fatal("consumed OAuth state was accepted twice") } }) diff --git a/backend/internal/auth/avatar.go b/backend/internal/auth/avatar.go new file mode 100644 index 000000000..befe868f0 --- /dev/null +++ b/backend/internal/auth/avatar.go @@ -0,0 +1,191 @@ +package auth + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "image" + "image/color" + _ "image/jpeg" + "image/png" + "io" + "mime/multipart" + "net/url" + "time" + + "yingce/backend/internal/kernel" + "yingce/backend/internal/model" + "yingce/backend/internal/repository" + + "gorm.io/gorm" +) + +const MaxProfileAvatarBytes int64 = 5 << 20 +const maxProfileAvatarPixels = 16_000_000 +const maxProfileAvatarSide = 512 + +func profileAvatarURL(user *model.User) string { + return "/api/auth/profile/avatar/" + url.PathEscape(user.ID) + "?v=" + url.QueryEscape(user.AvatarVersion) +} + +func (s *Service) avatarActor(actor *model.User) (*model.User, error) { + if actor == nil || actor.ID == "" { + return nil, kernel.Unauthorized("请先登录") + } + if actor.Status != model.UserStatusActive { + return nil, kernel.Forbidden("该账号已被禁用") + } + user, err := s.repo.User(actor.ID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, kernel.Unauthorized("登录状态已失效") + } + if err != nil { + return nil, err + } + if user.Status != model.UserStatusActive { + return nil, kernel.Forbidden("该账号已被禁用") + } + return user, nil +} + +func (s *Service) UpdateProfileAvatar(actor *model.User, header *multipart.FileHeader) (*AuthUser, error) { + user, err := s.avatarActor(actor) + if err != nil { + return nil, err + } + if header == nil { + return nil, kernel.BadAuthRequest("请选择头像图片") + } + if header.Size > MaxProfileAvatarBytes { + return nil, kernel.BadAuthRequest("头像图片不能超过 5 MiB") + } + file, err := header.Open() + if err != nil { + return nil, kernel.WrapAppError(400, "无法读取头像图片", err) + } + data, readErr := io.ReadAll(io.LimitReader(file, MaxProfileAvatarBytes+1)) + closeErr := file.Close() + if readErr != nil { + return nil, kernel.WrapAppError(400, "无法读取头像图片", readErr) + } + if closeErr != nil { + return nil, kernel.WrapAppError(500, "关闭头像文件失败", closeErr) + } + if int64(len(data)) > MaxProfileAvatarBytes { + return nil, kernel.BadAuthRequest("头像图片不能超过 5 MiB") + } + normalized, err := normalizeProfileAvatar(data) + if err != nil { + return nil, err + } + hash := sha256.Sum256(normalized) + return s.saveProfileAvatar(user, normalized, hex.EncodeToString(hash[:16]), "") +} + +func (s *Service) UseDefaultProfileAvatar(actor *model.User) (*AuthUser, error) { + return s.SelectDefaultProfileAvatar(actor, "person") +} + +func (s *Service) SelectDefaultProfileAvatar(actor *model.User, preset string) (*AuthUser, error) { + user, err := s.avatarActor(actor) + if err != nil { + return nil, err + } + switch preset { + case "person", "director", "camera", "cat", "rabbit", "robot", "rocket", "sparkles": + return s.saveProfileAvatar(user, nil, "", preset) + default: + return nil, kernel.BadAuthRequest("请选择有效的系统头像") + } +} + +func (s *Service) saveProfileAvatar(user *model.User, data []byte, version string, preset string) (*AuthUser, error) { + // Read linked identity metadata before writing, so an identity query failure + // cannot report an unsuccessful write after the avatar already committed. + result, err := s.PublicAuthUser(user) + if err != nil { + return nil, err + } + updated, err := s.repo.UpdateUserAvatar(user.ID, data, version, preset, time.Now()) + if errors.Is(err, repository.ErrProfileAvatarUserUnavailable) { + return nil, kernel.Forbidden("该账号已被禁用或不存在") + } + if err != nil { + return nil, err + } + result.User = *updated + result.AvatarURL = "" + if updated.AvatarMode == "custom" { + result.AvatarURL = profileAvatarURL(updated) + } + return &result, nil +} + +func (s *Service) ProfileAvatarContent(actor *model.User, targetUserID string) ([]byte, error) { + user, err := s.avatarActor(actor) + if err != nil { + return nil, err + } + if targetUserID != user.ID { + return nil, kernel.Forbidden("无权读取其他用户头像") + } + if user.AvatarMode != "custom" { + return nil, kernel.NotFound("未设置自定义头像") + } + avatar, err := s.repo.UserAvatar(user.ID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, kernel.NotFound("头像图片不存在") + } + if err != nil { + return nil, err + } + return avatar.Data, nil +} + +func normalizeProfileAvatar(data []byte) ([]byte, error) { + config, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil || (format != "png" && format != "jpeg") { + return nil, kernel.BadAuthRequest("头像仅支持有效的 PNG 或 JPEG 图片") + } + if config.Width <= 0 || config.Height <= 0 || config.Width > maxProfileAvatarPixels/config.Height { + return nil, kernel.BadAuthRequest("头像图片不能超过 1600 万像素") + } + source, decodedFormat, err := image.Decode(bytes.NewReader(data)) + if err != nil || decodedFormat != format || source.Bounds().Dx() != config.Width || source.Bounds().Dy() != config.Height { + return nil, kernel.BadAuthRequest("头像图片已损坏") + } + span := min(config.Width, config.Height) + side := min(span, maxProfileAvatarSide) + left := source.Bounds().Min.X + (config.Width-span)/2 + top := source.Bounds().Min.Y + (config.Height-span)/2 + output := image.NewRGBA(image.Rect(0, 0, side, side)) + // Interpolate premultiplied colors at pixel centers, matching a centered + // object-cover preview while preserving transparent PNG edges. + for y := 0; y < side; y++ { + fy := max(0, (float64(y)+0.5)*float64(span)/float64(side)-0.5) + y0 := int(fy) + y1 := min(y0+1, span-1) + wy := fy - float64(y0) + for x := 0; x < side; x++ { + fx := max(0, (float64(x)+0.5)*float64(span)/float64(side)-0.5) + x0 := int(fx) + x1 := min(x0+1, span-1) + wx := fx - float64(x0) + r0, g0, b0, a0 := source.At(left+x0, top+y0).RGBA() + r1, g1, b1, a1 := source.At(left+x1, top+y0).RGBA() + r2, g2, b2, a2 := source.At(left+x0, top+y1).RGBA() + r3, g3, b3, a3 := source.At(left+x1, top+y1).RGBA() + channel := func(c0, c1, c2, c3 uint32) uint8 { + value := (float64(c0)*(1-wx)+float64(c1)*wx)*(1-wy) + (float64(c2)*(1-wx)+float64(c3)*wx)*wy + return uint8(value/257 + 0.5) + } + output.SetRGBA(x, y, color.RGBA{R: channel(r0, r1, r2, r3), G: channel(g0, g1, g2, g3), B: channel(b0, b1, b2, b3), A: channel(a0, a1, a2, a3)}) + } + } + var encoded bytes.Buffer + if err := png.Encode(&encoded, output); err != nil { + return nil, kernel.WrapAppError(500, "头像图片处理失败", err) + } + return encoded.Bytes(), nil +} diff --git a/backend/internal/auth/avatar_test.go b/backend/internal/auth/avatar_test.go new file mode 100644 index 000000000..0a3aad806 --- /dev/null +++ b/backend/internal/auth/avatar_test.go @@ -0,0 +1,371 @@ +package auth + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "hash/crc32" + "image" + "image/color" + "image/jpeg" + "image/png" + "mime/multipart" + "net/http/httptest" + "strings" + "testing" + + "yingce/backend/internal/kernel" + "yingce/backend/internal/model" + "yingce/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func avatarTestService(t *testing.T) (*Service, *gorm.DB, *model.User) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+kernel.NewID()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { _ = sqlDB.Close() }) + if err := db.AutoMigrate(&model.User{}, &model.UserAvatar{}, &model.UserIdentity{}); err != nil { + t.Fatal(err) + } + user := &model.User{ID: "avatar-user", Username: "avatar-user", DisplayName: "Before", Role: model.UserRoleUser, Status: model.UserStatusActive, PasswordHash: "preserved-hash"} + if err := db.Create(user).Error; err != nil { + t.Fatal(err) + } + return New(repository.New(db), nil, nil), db, user +} + +func avatarFileHeader(t *testing.T, data []byte) *multipart.FileHeader { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", "untrusted-name.txt") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(data); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("PUT", "/", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + if err := req.ParseMultipartForm(MaxProfileAvatarBytes + 1024); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = req.MultipartForm.RemoveAll() }) + return req.MultipartForm.File["file"][0] +} + +func avatarPNG(t *testing.T, source image.Image) []byte { + t.Helper() + var data bytes.Buffer + if err := png.Encode(&data, source); err != nil { + t.Fatal(err) + } + return data.Bytes() +} + +func avatarStatus(t *testing.T, err error, status int) { + t.Helper() + var appErr *kernel.AppError + if !errors.As(err, &appErr) || appErr.Status != status { + t.Fatalf("error = %v, want status %d", err, status) + } +} + +func TestProfileAvatarLifecyclePersistsAndOverridesLinkedAvatar(t *testing.T) { + s, db, actor := avatarTestService(t) + identity := model.UserIdentity{ID: "identity-avatar", UserID: actor.ID, Provider: "linuxdo", Subject: "123", AvatarURL: "https://example.com/linked.png"} + if err := db.Create(&identity).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&model.User{}).Where("id = ?", actor.ID).Update("display_name", "Latest").Error; err != nil { + t.Fatal(err) + } + result, err := s.UpdateProfileAvatar(actor, avatarFileHeader(t, avatarPNG(t, image.NewRGBA(image.Rect(0, 0, 8, 4))))) + if err != nil { + t.Fatal(err) + } + if result.AvatarMode != "custom" || !strings.HasPrefix(result.AvatarURL, "/api/auth/profile/avatar/avatar-user?v=") || result.IdentityID != "123" || result.DisplayName != "Latest" || result.PasswordHash != "preserved-hash" { + t.Fatalf("unexpected uploaded profile: %#v", result) + } + publicJSON, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(publicJSON, []byte(`"avatarVersion"`)) || bytes.Contains(publicJSON, []byte("preserved-hash")) || !bytes.Contains(publicJSON, []byte(`"avatarMode":"custom"`)) { + t.Fatalf("unexpected public avatar JSON: %s", publicJSON) + } + fresh, err := s.repo.User(actor.ID) + if err != nil { + t.Fatal(err) + } + refreshed, err := s.PublicAuthUser(fresh) + if err != nil || refreshed.AvatarURL != result.AvatarURL { + t.Fatalf("refreshed avatar = %q, error %v", refreshed.AvatarURL, err) + } + data, err := s.ProfileAvatarContent(actor, actor.ID) + if err != nil { + t.Fatal(err) + } + config, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil || format != "png" || config.Width != 4 || config.Height != 4 { + t.Fatalf("normalized config = %#v, %s, %v", config, format, err) + } + _, err = s.ProfileAvatarContent(actor, "other-user") + avatarStatus(t, err, 403) + result, err = s.UseDefaultProfileAvatar(actor) + if err != nil { + t.Fatal(err) + } + if result.AvatarMode != "default" || result.AvatarPreset != "person" || result.AvatarURL != "" || result.AvatarVersion != "" || result.IdentityID != "123" { + t.Fatalf("unexpected default profile: %#v", result) + } + var count int64 + if err := db.Model(&model.UserAvatar{}).Count(&count).Error; err != nil || count != 0 { + t.Fatalf("remaining avatar blobs = %d, error %v", count, err) + } + fresh, err = s.repo.User(actor.ID) + if err != nil { + t.Fatal(err) + } + refreshed, err = s.PublicAuthUser(fresh) + if err != nil || refreshed.AvatarURL != "" || refreshed.IdentityProvider != "linuxdo" { + t.Fatalf("default selection did not survive refresh: %#v, %v", refreshed, err) + } + _, err = s.ProfileAvatarContent(actor, actor.ID) + avatarStatus(t, err, 404) +} + +func TestProfileAvatarRequiresActiveAuthenticatedOwner(t *testing.T) { + s, db, actor := avatarTestService(t) + _, err := s.UseDefaultProfileAvatar(nil) + avatarStatus(t, err, 401) + _, err = s.ProfileAvatarContent(&model.User{}, actor.ID) + avatarStatus(t, err, 401) + _, err = s.UpdateProfileAvatar(&model.User{ID: actor.ID, Status: model.UserStatusDisabled}, nil) + avatarStatus(t, err, 403) + if err := db.Model(actor).Update("status", model.UserStatusDisabled).Error; err != nil { + t.Fatal(err) + } + staleActive := &model.User{ID: actor.ID, Status: model.UserStatusActive} + _, err = s.UseDefaultProfileAvatar(staleActive) + avatarStatus(t, err, 403) + _, err = s.ProfileAvatarContent(staleActive, actor.ID) + avatarStatus(t, err, 403) + _, err = s.repo.UpdateUserAvatar(actor.ID, []byte("cannot-write"), "version", "", actor.UpdatedAt) + if !errors.Is(err, repository.ErrProfileAvatarUserUnavailable) { + t.Fatalf("repository permitted disabled user write: %v", err) + } +} + +func TestProfileAvatarRejectsInvalidUploadsWithoutChangingSelection(t *testing.T) { + s, _, actor := avatarTestService(t) + valid := avatarPNG(t, image.NewRGBA(image.Rect(0, 0, 2, 2))) + before, err := s.UpdateProfileAvatar(actor, avatarFileHeader(t, valid)) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + data []byte + }{ + {name: "svg", data: []byte(``)}, + {name: "corrupt png", data: valid[:len(valid)-8]}, + {name: "empty", data: nil}, + {name: "oversized actual bytes", data: bytes.Repeat([]byte("x"), int(MaxProfileAvatarBytes+1))}, + } { + t.Run(test.name, func(t *testing.T) { + header := avatarFileHeader(t, test.data) + header.Size = 1 // Enforce bytes read even if metadata understates size. + _, err := s.UpdateProfileAvatar(actor, header) + avatarStatus(t, err, 400) + }) + } + fresh, err := s.repo.User(actor.ID) + if err != nil { + t.Fatal(err) + } + if fresh.AvatarMode != "custom" || fresh.AvatarVersion != before.AvatarVersion { + t.Fatalf("rejected upload changed selection: %#v", fresh) + } + _, err = s.UpdateProfileAvatar(actor, nil) + avatarStatus(t, err, 400) +} + +func TestNormalizeProfileAvatarCenterCropResizeAndJPEG(t *testing.T) { + wide := image.NewRGBA(image.Rect(0, 0, 6, 2)) + for y := 0; y < 2; y++ { + for x := 0; x < 6; x++ { + pixel := color.RGBA{R: 255, A: 255} + if x == 2 || x == 3 { + pixel = color.RGBA{G: 255, A: 255} + } + wide.SetRGBA(x, y, pixel) + } + } + normalized, err := normalizeProfileAvatar(avatarPNG(t, wide)) + if err != nil { + t.Fatal(err) + } + cropped, err := png.Decode(bytes.NewReader(normalized)) + if err != nil { + t.Fatal(err) + } + if cropped.Bounds().Dx() != 2 || cropped.Bounds().Dy() != 2 { + t.Fatal("crop was not square") + } + for y := 0; y < 2; y++ { + for x := 0; x < 2; x++ { + r, g, b, _ := cropped.At(x, y).RGBA() + if r != 0 || g != 65535 || b != 0 { + t.Fatalf("crop includes non-centered pixel at %d,%d", x, y) + } + } + } + var jpegData bytes.Buffer + if err := jpeg.Encode(&jpegData, image.NewRGBA(image.Rect(0, 0, 1024, 768)), nil); err != nil { + t.Fatal(err) + } + normalized, err = normalizeProfileAvatar(jpegData.Bytes()) + if err != nil { + t.Fatal(err) + } + config, format, err := image.DecodeConfig(bytes.NewReader(normalized)) + if err != nil || format != "png" || config.Width != 512 || config.Height != 512 { + t.Fatalf("resized JPEG = %#v, %s, %v", config, format, err) + } +} + +func TestNormalizeProfileAvatarRejectsPixelLimitBeforeDecode(t *testing.T) { + data := avatarPNG(t, image.NewRGBA(image.Rect(0, 0, 1, 1))) + // Replace IHDR dimensions and checksum without allocating a large image. + binary.BigEndian.PutUint32(data[16:20], 5000) + binary.BigEndian.PutUint32(data[20:24], 5000) + binary.BigEndian.PutUint32(data[29:33], crc32.ChecksumIEEE(data[12:29])) + _, err := normalizeProfileAvatar(data) + avatarStatus(t, err, 400) + if err.Error() != "头像图片不能超过 1600 万像素" { + t.Fatalf("pixel cap was not checked before decode: %v", err) + } +} + +func TestProfileAvatarTransactionRollsBackBlobFailures(t *testing.T) { + s, db, actor := avatarTestService(t) + if _, err := s.SelectDefaultProfileAvatar(actor, "cat"); err != nil { + t.Fatal(err) + } + firstData := avatarPNG(t, image.NewRGBA(image.Rect(0, 0, 2, 2))) + before, err := s.UpdateProfileAvatar(actor, avatarFileHeader(t, firstData)) + if err != nil { + t.Fatal(err) + } + beforeBlob, err := s.ProfileAvatarContent(actor, actor.ID) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + sql string + call func() error + }{ + {name: "update", sql: "CREATE TRIGGER fail_avatar_update BEFORE INSERT ON user_avatars BEGIN SELECT RAISE(ABORT, 'avatar write failed'); END", call: func() error { + _, err := s.UpdateProfileAvatar(actor, avatarFileHeader(t, avatarPNG(t, image.NewRGBA(image.Rect(0, 0, 4, 4))))) + return err + }}, + {name: "delete", sql: "CREATE TRIGGER fail_avatar_delete BEFORE DELETE ON user_avatars BEGIN SELECT RAISE(ABORT, 'avatar delete failed'); END", call: func() error { + _, err := s.UseDefaultProfileAvatar(actor) + return err + }}, + } { + t.Run(test.name, func(t *testing.T) { + if err := db.Exec(test.sql).Error; err != nil { + t.Fatal(err) + } + if err := test.call(); err == nil { + t.Fatal("storage failure was swallowed") + } + fresh, err := s.repo.User(actor.ID) + if err != nil || fresh.AvatarMode != "custom" || fresh.AvatarVersion != before.AvatarVersion || fresh.AvatarPreset != "cat" { + t.Fatalf("avatar selection was not rolled back: %#v, %v", fresh, err) + } + data, err := s.ProfileAvatarContent(actor, actor.ID) + if err != nil || !bytes.Equal(data, beforeBlob) { + t.Fatalf("avatar blob was not retained after rollback: %v", err) + } + }) + } +} + +func TestProfileAvatarPresetsValidatePersistAndKeepLinkedIdentity(t *testing.T) { + s, db, actor := avatarTestService(t) + if err := db.Create(&model.UserIdentity{ID: "preset-identity", UserID: actor.ID, Provider: "linuxdo", Subject: "123", AvatarURL: "https://example.com/linked.png"}).Error; err != nil { + t.Fatal(err) + } + for _, preset := range []string{"person", "director", "camera", "cat", "rabbit", "robot", "rocket", "sparkles"} { + t.Run(preset, func(t *testing.T) { + result, err := s.SelectDefaultProfileAvatar(actor, preset) + if err != nil { + t.Fatal(err) + } + if result.AvatarMode != "default" || result.AvatarPreset != preset || result.AvatarURL != "" || result.AvatarVersion != "" || result.IdentityID != "123" { + t.Fatalf("unexpected preset response: %#v", result) + } + data, err := json.Marshal(result) + if err != nil || !bytes.Contains(data, []byte(`"avatarPreset":"`+preset+`"`)) || bytes.Contains(data, []byte(`"avatarVersion"`)) { + t.Fatalf("unexpected serialized preset: %s, %v", data, err) + } + fresh, err := s.repo.User(actor.ID) + if err != nil { + t.Fatal(err) + } + refreshed, err := s.PublicAuthUser(fresh) + if err != nil || refreshed.AvatarPreset != preset || refreshed.AvatarURL != "" { + t.Fatalf("preset did not survive refresh: %#v, %v", refreshed, err) + } + }) + } + before, err := s.UpdateProfileAvatar(actor, avatarFileHeader(t, avatarPNG(t, image.NewRGBA(image.Rect(0, 0, 2, 2))))) + if err != nil { + t.Fatal(err) + } + if before.AvatarPreset != "sparkles" || before.AvatarMode != "custom" { + t.Fatalf("custom upload lost preset preference: %#v", before) + } + beforeBlob, err := s.ProfileAvatarContent(actor, actor.ID) + if err != nil { + t.Fatal(err) + } + for _, invalid := range []string{"", "Cat", " cat", "cat ", "default", "../person", "person,cat", "https://example.com/icon.svg"} { + _, err := s.SelectDefaultProfileAvatar(actor, invalid) + avatarStatus(t, err, 400) + } + fresh, err := s.repo.User(actor.ID) + if err != nil || fresh.AvatarPreset != before.AvatarPreset || fresh.AvatarVersion != before.AvatarVersion || fresh.AvatarMode != "custom" { + t.Fatalf("invalid selection mutated user: %#v, %v", fresh, err) + } + afterBlob, err := s.ProfileAvatarContent(actor, actor.ID) + if err != nil || !bytes.Equal(beforeBlob, afterBlob) { + t.Fatalf("invalid preset changed custom blob: %v", err) + } + selected, err := s.SelectDefaultProfileAvatar(actor, "rabbit") + if err != nil || selected.AvatarPreset != "rabbit" || selected.AvatarVersion != "" || selected.AvatarMode != "default" { + t.Fatalf("custom-to-preset failed: %#v, %v", selected, err) + } + var count int64 + if err := db.Model(&model.UserAvatar{}).Where("user_id = ?", actor.ID).Count(&count).Error; err != nil || count != 0 { + t.Fatalf("custom blob not removed by preset: %d, %v", count, err) + } +} diff --git a/backend/internal/auth/linuxdo.go b/backend/internal/auth/linuxdo.go index f7928220f..6f2a2eb20 100644 --- a/backend/internal/auth/linuxdo.go +++ b/backend/internal/auth/linuxdo.go @@ -80,8 +80,9 @@ type linuxDOSettingValue struct { } type LinuxDOCallbackResult struct { - Session *AuthSessionResult - Next string + Session *AuthSessionResult + Next string + Reauthenticated bool } func (s *Service) AdminLinuxDOSetting(actor *model.User) (*PublicLinuxDOSetting, error) { @@ -155,14 +156,44 @@ func (s *Service) BeginLinuxDOLogin(nextPath string, acceptedTerms bool) (string if !setting.Enabled { return "", kernel.Forbidden("Linux.do 登录尚未启用") } + return s.beginLinuxDOAuthorization(setting, &model.OAuthState{NextPath: safeOAuthNext(nextPath), AcceptedTerms: acceptedTerms}) +} + +func (s *Service) BeginLinuxDOReauthentication(actor *model.User, cookie string) (string, error) { + user, session, err := s.profileSession(actor, cookie) + if err != nil { + return "", err + } + if _, err := s.repo.UserIdentityForUser(user.ID, "linuxdo"); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", kernel.Forbidden("该账号没有关联 Linux.do 身份") + } + return "", err + } + _, setting, err := s.readLinuxDOSetting() + if err != nil { + return "", err + } + if !setting.Enabled { + return "", kernel.Forbidden("Linux.do 登录尚未启用") + } + return s.beginLinuxDOAuthorization(setting, &model.OAuthState{NextPath: "/settings?section=profile&reauth=success", ReauthUserID: user.ID, ReauthSessionID: session.ID}) +} + +func (s *Service) LinuxDOReauthenticationState(stateValue string) (bool, error) { + if strings.TrimSpace(stateValue) == "" { + return false, nil + } + return s.repo.LinuxDOReauthenticationState(HashToken(stateValue)) +} + +func (s *Service) beginLinuxDOAuthorization(setting linuxDOSettingValue, record *model.OAuthState) (string, error) { state := RandomToken() verifier := RandomToken() challengeBytes := sha256.Sum256([]byte(verifier)) challenge := base64.RawURLEncoding.EncodeToString(challengeBytes[:]) - if err := s.repo.CreateOAuthState(&model.OAuthState{ - ID: kernel.NewID(), Provider: "linuxdo", StateHash: HashToken(state), CodeVerifier: verifier, - NextPath: safeOAuthNext(nextPath), AcceptedTerms: acceptedTerms, ExpiresAt: time.Now().Add(10 * time.Minute), - }); err != nil { + record.ID, record.Provider, record.StateHash, record.CodeVerifier, record.ExpiresAt = kernel.NewID(), "linuxdo", HashToken(state), verifier, time.Now().Add(10*time.Minute) + if err := s.repo.CreateOAuthState(record); err != nil { return "", err } authorizeURL, err := url.Parse(setting.AuthorizationURL) @@ -183,7 +214,25 @@ func (s *Service) BeginLinuxDOLogin(nextPath string, acceptedTerms bool) (string return authorizeURL.String(), nil } -func (s *Service) CompleteLinuxDOLogin(stateValue string, code string) (*LinuxDOCallbackResult, error) { +func (s *Service) CompleteLinuxDOLogin(stateValue string, code string, cookieValue string, contexts ...LoginContext) (result *LinuxDOCallbackResult, err error) { + var context LoginContext + if len(contexts) > 0 { + context = contexts[0] + } + event := NewLoginLog("linuxdo", "login", context) + logFailure := true + defer func() { + if err != nil && logFailure { + err = s.recordLoginFailure(event, err) + } + }() + if strings.TrimSpace(stateValue) != "" { + reauth, stateErr := s.LinuxDOReauthenticationState(stateValue) + if stateErr != nil { + return nil, stateErr + } + logFailure = !reauth + } if strings.TrimSpace(stateValue) == "" || strings.TrimSpace(code) == "" { return nil, kernel.BadAuthRequest("Linux.do 登录回调缺少必要参数") } @@ -194,10 +243,28 @@ func (s *Service) CompleteLinuxDOLogin(stateValue string, code string) (*LinuxDO if err != nil { return nil, err } + logFailure = state.ReauthUserID == "" && state.ReauthSessionID == "" _, setting, err := s.readLinuxDOSetting() if err != nil { return nil, err } + if !setting.Enabled { + return nil, kernel.Forbidden("Linux.do 登录尚未启用") + } + var reauthUser *model.User + var reauthSession *model.AuthSession + if state.ReauthUserID != "" || state.ReauthSessionID != "" { + if cookieValue == "" { + return nil, kernel.Unauthorized("重新验证需要当前登录会话") + } + reauthUser, reauthSession, err = s.profileSession(&model.User{ID: state.ReauthUserID}, cookieValue) + if err != nil { + return nil, err + } + if reauthSession.ID != state.ReauthSessionID { + return nil, kernel.Unauthorized("重新验证与当前登录会话不匹配") + } + } accessToken, err := exchangeLinuxDOCode(setting, code, state.CodeVerifier) if err != nil { return nil, err @@ -210,6 +277,12 @@ func (s *Service) CompleteLinuxDOLogin(stateValue string, code string) (*LinuxDO if subject == "" { return nil, errors.New("Linux.do 用户信息缺少稳定用户 ID") } + if reauthUser != nil { + if err := s.repo.ReauthenticateLinuxDOSession(reauthUser, reauthSession, subject, time.Now()); err != nil { + return nil, profileWriteError(err) + } + return &LinuxDOCallbackResult{Next: "/settings?section=profile&reauth=success", Reauthenticated: true}, nil + } providerUsername := profileString(profile, setting.UsernameField) displayName := kernel.FirstNonEmpty(profileString(profile, setting.DisplayNameField), providerUsername, "Linux.do 用户") avatarURL := profileString(profile, setting.AvatarField) @@ -227,6 +300,7 @@ func (s *Service) CompleteLinuxDOLogin(stateValue string, code string) (*LinuxDO return nil, err } } else if errors.Is(err, gorm.ErrRecordNotFound) { + event.Source = "registration" policy, policyErr := s.verificationPolicy() if policyErr != nil { return nil, policyErr @@ -254,6 +328,7 @@ func (s *Service) CompleteLinuxDOLogin(stateValue string, code string) (*LinuxDO } else { return nil, err } + loginLogUser(event, user) if user.Status != model.UserStatusActive { return nil, kernel.Forbidden("该账号已被禁用") } @@ -266,8 +341,7 @@ func (s *Service) CompleteLinuxDOLogin(stateValue string, code string) (*LinuxDO if err := s.repo.UpdateUserLoginTime(user.ID, now); err != nil { return nil, err } - s.host.RecordActivity(user.ID, "login", 1) - session, err := s.createAuthSession(user) + session, err := s.createAuthSession(user, event) if err != nil { return nil, err } diff --git a/backend/internal/auth/login_logs.go b/backend/internal/auth/login_logs.go new file mode 100644 index 000000000..8b4d8399c --- /dev/null +++ b/backend/internal/auth/login_logs.go @@ -0,0 +1,96 @@ +package auth + +import ( + "errors" + "strings" + "time" + "unicode" + "yingce/backend/internal/kernel" + "yingce/backend/internal/model" +) + +type LoginContext struct { + IP string + UserAgent string +} + +func loginLogText(value string, max int) string { + value = strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, value) + runes := []rune(strings.TrimSpace(value)) + if len(runes) > max { + runes = runes[:max] + } + return string(runes) +} + +func MaskLoginAccount(account string) string { + account = loginLogText(account, 160) + if at := strings.LastIndex(account, "@"); at >= 0 { + if at == 0 { + return "***" + account[at:] + } + return string([]rune(account[:at])[:1]) + "***" + account[at:] + } + digits := strings.TrimPrefix(account, "+") + if len(digits) >= 8 && strings.Trim(digits, "0123456789") == "" { + return digits[:3] + "****" + digits[len(digits)-4:] + } + if len(digits) >= 4 && strings.Trim(digits, "0123456789") == "" { + return digits[:1] + "****" + digits[len(digits)-1:] + } + return account +} + +func NewLoginLog(method, source string, context LoginContext) *model.LoginLog { + return &model.LoginLog{ID: kernel.NewID(), Event: "login", Method: method, Source: source, Result: "success", IP: loginLogText(context.IP, 64), UserAgent: loginLogText(context.UserAgent, 512), CreatedAt: time.Now().UTC()} +} + +func loginLogUser(event *model.LoginLog, user *model.User) { + event.UserID = &user.ID + event.Username, event.DisplayName, event.Account = user.Username, user.DisplayName, user.Username +} + +// 对外错误文案已由 AppError 约束;内部错误仅记固定分类,避免写入 SQL/上游密钥。 +func setLoginFailure(event *model.LoginLog, original error) { + event.Result, event.Reason, event.Message = "failed", "internal_error", "系统处理失败" + if event.Event == "login" || event.UserID == nil { + event.SessionRef = "" + } + var appErr *kernel.AppError + if errors.As(original, &appErr) { + event.Reason = string(appErr.Reason) + event.Message = loginLogText(appErr.Message, 500) + } +} + +func (s *Service) recordLoginFailure(event *model.LoginLog, original error) error { + setLoginFailure(event, original) + if err := s.repo.AppendLoginLog(event); err != nil { + return kernel.WrapAppError(500, "登录日志写入失败,请稍后重试", errors.Join(original, err)) + } + return original +} + +func (s *Service) RecordRejectedLogin(method string, context LoginContext, status int) error { + event := NewLoginLog(method, "login", context) + message := "登录请求被拒绝" + if status == 400 { + message = "登录请求参数无效" + } + if status == 429 { + message = "登录请求过于频繁" + } + if status >= 500 { + message = "登录服务暂时不可用" + } + setLoginFailure(event, kernel.NewAppError(status, message)) + if err := s.repo.AppendLoginLog(event); err != nil { + return kernel.WrapAppError(500, "登录日志写入失败", err) + } + return nil +} diff --git a/backend/internal/auth/login_logs_test.go b/backend/internal/auth/login_logs_test.go new file mode 100644 index 000000000..8edfd0ea9 --- /dev/null +++ b/backend/internal/auth/login_logs_test.go @@ -0,0 +1,104 @@ +package auth + +import ( + "encoding/json" + "strings" + "testing" + "time" + "yingce/backend/internal/model" + "yingce/backend/internal/repository" +) + +func TestLoginLogMasksAttemptedContacts(t *testing.T) { + for _, account := range []string{"unknown@example.com", "+8613812345678", "1234567", "\n1234567\t"} { + masked := MaskLoginAccount(account) + if !strings.Contains(masked, "*") || strings.Contains(masked, "unknown") || strings.Contains(masked, "1234567") || strings.ContainsAny(masked, "\n\t") { + t.Fatalf("contact not masked: %q", masked) + } + } +} + +func TestLoginLogsExcludeIncompleteProfileReauthentication(t *testing.T) { + s, db, user, cookie := newProfileTestService(t) + state := model.OAuthState{ID: "reauth-state", Provider: "linuxdo", StateHash: HashToken("profile-reauth-state"), ReauthUserID: user.ID, ReauthSessionID: "session-one", ExpiresAt: time.Now().Add(time.Hour)} + if err := db.Create(&state).Error; err != nil { + t.Fatal(err) + } + if _, err := s.CompleteLinuxDOLogin("profile-reauth-state", "", cookie); err == nil { + t.Fatal("missing code accepted") + } + var count int64 + if err := db.Model(&model.LoginLog{}).Count(&count).Error; err != nil || count != 0 { + t.Fatalf("profile reauthentication logged as login: %d %v", count, err) + } +} + +func TestLoginLogRegistrationSource(t *testing.T) { + s, db := newRegistrationTestService(t) + result, err := s.Register(RegisterRequest{Username: "creator", Password: "test-password", AcceptedTerms: true, LoginContext: LoginContext{IP: "192.0.2.2", UserAgent: "test-client"}}) + if err != nil { + t.Fatal(err) + } + var event model.LoginLog + if err := db.First(&event).Error; err != nil { + t.Fatal(err) + } + if event.Source != "registration" || event.Result != "success" || event.UserID == nil || *event.UserID != result.User.ID || event.IP != "192.0.2.2" { + t.Fatalf("registration log: %#v", event) + } + if event.SessionRef == "" || strings.Contains(result.Session, event.SessionRef) { + t.Fatal("session ref exposes cookie identifier") + } +} + +func TestLoginLogVerificationMethodsAndInvalidCode(t *testing.T) { + for _, method := range []string{"email", "sms"} { + for _, valid := range []bool{true, false} { + t.Run(method+map[bool]string{true: "/success", false: "/failure"}[valid], func(t *testing.T) { + s, db, u, _ := newProfileTestService(t) + policy := VerificationPolicy{SMSLogin: true, EmailLogin: true} + raw, _ := json.Marshal(policy) + if err := db.Create(&model.SystemSetting{Key: authPolicyKey, ValueJSON: string(raw)}).Error; err != nil { + t.Fatal(err) + } + secret := strings.Repeat("s", 64) + v := model.AuthVerification{ID: "login-verification", TokenHash: HashToken(secret), Purpose: "login", Method: method, UserID: u.ID, CredentialHash: repository.ProfileCredentialHash(u.PasswordHash), PolicyHash: policy.hash(), Ready: true, ExpiresAt: time.Now().Add(time.Hour)} + if method == "email" { + v.Email = u.Email + v.EmailHash, _ = s.verificationHash(v.ID + ":email:" + v.Email + ":123456") + if err := db.Model(u).Update("email_verified_at", time.Now()).Error; err != nil { + t.Fatal(err) + } + } else { + v.Phone = "+8613812345678" + v.PhoneHash, _ = s.verificationHash(v.ID + ":sms:" + v.Phone + ":123456") + if err := db.Model(u).Updates(map[string]any{"phone": v.Phone, "phone_verified_at": time.Now()}).Error; err != nil { + t.Fatal(err) + } + } + if err := db.Create(&v).Error; err != nil { + t.Fatal(err) + } + code := "123456" + if !valid { + code = "000000" + } + _, err := s.LoginVerification(VerificationConfirm{Ticket: v.ID + "." + secret, EmailCode: code, SMSCode: code, LoginContext: LoginContext{IP: "192.0.2.3"}}) + if (err == nil) != valid { + t.Fatalf("verification error: %v", err) + } + var event model.LoginLog + if err := db.First(&event).Error; err != nil { + t.Fatal(err) + } + want := "success" + if !valid { + want = "failed" + } + if event.Method != method || event.Result != want || event.UserID == nil || *event.UserID != u.ID { + t.Fatalf("verification log: %#v", event) + } + }) + } + } +} diff --git a/backend/internal/auth/login_status_test.go b/backend/internal/auth/login_status_test.go index 89b36be1f..d9f1e5c27 100644 --- a/backend/internal/auth/login_status_test.go +++ b/backend/internal/auth/login_status_test.go @@ -35,7 +35,7 @@ func disableBeforeLoginWrite(t *testing.T, db *gorm.DB, userID string) *bool { return &fired } -func assertLoginPreservesDisable(t *testing.T, svc *Service, db *gorm.DB, fired *bool, result *AuthSessionResult) { +func assertLoginPreservesDisable(t *testing.T, db *gorm.DB, fired *bool, loginErr error) { t.Helper() if !*fired { t.Fatal("concurrent disable was not injected") @@ -53,8 +53,15 @@ func assertLoginPreservesDisable(t *testing.T, svc *Service, db *gorm.DB, fired if user.LastLoginAt == nil || user.UpdatedAt.Before(*user.LastLoginAt) { t.Error("login timestamps were not updated") } - if _, err := svc.CurrentUser(result.Session); err == nil { - t.Error("disabled user can use the completed login session") + if loginErr == nil { + t.Error("login issued a session after concurrent disable") + } + var sessions int64 + if err := db.Model(&model.AuthSession{}).Where("user_id = ?", user.ID).Count(&sessions).Error; err != nil { + t.Fatal(err) + } + if sessions != 0 { + t.Error("disabled user received a login session") } } @@ -68,11 +75,8 @@ func TestPasswordLoginPreservesConcurrentDisable(t *testing.T) { t.Fatal(err) } fired := disableBeforeLoginWrite(t, db, "member") - result, err := svc.Login(LoginRequest{Username: "member", Password: "password"}) - if err != nil { - t.Fatal(err) - } - assertLoginPreservesDisable(t, svc, db, fired, result) + _, err = svc.Login(LoginRequest{Username: "member", Password: "password"}) + assertLoginPreservesDisable(t, db, fired, err) } func TestLinuxDOLoginPreservesConcurrentDisable(t *testing.T) { @@ -119,9 +123,6 @@ func TestLinuxDOLoginPreservesConcurrentDisable(t *testing.T) { t.Fatal(err) } fired := disableBeforeLoginWrite(t, db, "member") - result, err := svc.CompleteLinuxDOLogin("state-value", "code") - if err != nil { - t.Fatal(err) - } - assertLoginPreservesDisable(t, svc, db, fired, result.Session) + _, err = svc.CompleteLinuxDOLogin("state-value", "code", "") + assertLoginPreservesDisable(t, db, fired, err) } diff --git a/backend/internal/auth/password_reset_test.go b/backend/internal/auth/password_reset_test.go index e7fe22c92..61e744d1f 100644 --- a/backend/internal/auth/password_reset_test.go +++ b/backend/internal/auth/password_reset_test.go @@ -274,7 +274,7 @@ func newPasswordResetTestService(t *testing.T) (*Service, *gorm.DB) { if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.EmailVerificationCode{}, &model.SystemSetting{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.LoginLog{}, &model.EmailVerificationCode{}, &model.SystemSetting{}); err != nil { t.Fatal(err) } settingJSON, err := json.Marshal(EmailSettingValue{Enabled: true, Host: "smtp.example.com", Port: 587, Encryption: "starttls", FromEmail: "noreply@example.com", FromName: "影策", RegistrationAllowedDomains: []string{"example.com"}}) diff --git a/backend/internal/auth/verification.go b/backend/internal/auth/verification.go index 3bfa7d0fe..db91ffeca 100644 --- a/backend/internal/auth/verification.go +++ b/backend/internal/auth/verification.go @@ -30,9 +30,10 @@ type VerificationTicket struct { RetryAfter int `json:"retryAfter"` } type VerificationConfirm struct { - Ticket string `json:"ticket"` - EmailCode string `json:"emailCode"` - SMSCode string `json:"smsCode"` + LoginContext LoginContext `json:"-"` + Ticket string `json:"ticket"` + EmailCode string `json:"emailCode"` + SMSCode string `json:"smsCode"` } func (s *Service) verificationHash(value string) (string, error) { @@ -283,7 +284,30 @@ func (s *Service) verifyTicket(purpose string, req VerificationConfirm) (*model. } return v, nil } -func (s *Service) LoginVerification(req VerificationConfirm) (*AuthSessionResult, error) { +func (s *Service) LoginVerification(req VerificationConfirm) (result *AuthSessionResult, err error) { + event := NewLoginLog("verification", "login", req.LoginContext) + defer func() { + if err != nil { + err = s.recordLoginFailure(event, err) + } + }() + if id, secret, ok := strings.Cut(req.Ticket, "."); ok && len(id) <= 36 && len(secret) == 64 { + candidate, lookupErr := s.repo.AuthVerification(id) + if lookupErr != nil && !errors.Is(lookupErr, gorm.ErrRecordNotFound) { + return nil, lookupErr + } + if lookupErr == nil && candidate.Purpose == "login" && hmac.Equal([]byte(candidate.TokenHash), []byte(HashToken(secret))) { + event.Method = candidate.Method + event.Account = MaskLoginAccount(kernel.FirstNonEmpty(candidate.Email, candidate.Phone)) + if candidate.UserID != "" { + user, lookupErr := s.repo.User(candidate.UserID) + if lookupErr != nil { + return nil, lookupErr + } + loginLogUser(event, user) + } + } + } v, err := s.verifyTicket("login", req) if err != nil { return nil, err @@ -298,8 +322,8 @@ func (s *Service) LoginVerification(req VerificationConfirm) (*AuthSessionResult if err := s.host.EnsureSignupBonus(user.ID); err != nil { return nil, err } - s.host.RecordActivity(user.ID, "login", 1) - return s.createAuthSession(user) + event.Method = v.Method + return s.createAuthSession(user, event) } func (s *Service) BindVerification(actor *model.User, req VerificationConfirm) (*AuthUser, error) { if actor == nil { diff --git a/backend/internal/database/login_logs_migration_test.go b/backend/internal/database/login_logs_migration_test.go new file mode 100644 index 000000000..19296d92b --- /dev/null +++ b/backend/internal/database/login_logs_migration_test.go @@ -0,0 +1,40 @@ +package database + +import ( + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "path/filepath" + "testing" + "yingce/backend/internal/model" +) + +func TestLoginLogMigrationUpgradesExistingSchema(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "migration.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := db.DB() + t.Cleanup(func() { _ = sqlDB.Close() }) + if err := MigrateSchema(db); err != nil { + t.Fatal(err) + } + if err := db.Migrator().DropTable(&model.LoginLog{}); err != nil { + t.Fatal(err) + } + if err := db.Exec("DELETE FROM schema_migrations WHERE version = 53").Error; err != nil { + t.Fatal(err) + } + if err := MigrateSchema(db); err != nil { + t.Fatal(err) + } + if !db.Migrator().HasTable(&model.LoginLog{}) { + t.Fatal("existing schema lacks login logs") + } + if err := MigrateSchema(db); err != nil { + t.Fatal("migration is not repeatable", err) + } + status, err := ReadSchemaStatus(db) + if err != nil || !status.Ready || status.Current != CurrentSchemaVersion { + t.Fatalf("schema status: %#v %v", status, err) + } +} diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index 9193ac4d5..52ec741b5 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 50 +const CurrentSchemaVersion int64 = 53 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -166,6 +166,18 @@ var schemaMigrations = []migration{ {version: 50, name: "canvas_mutation_receipts", checksum: "sha256:canvas-mutation-receipts-v50-20261011", apply: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.CanvasMutationReceipt{}) }}, + {version: 51, name: "personal_profile_security", checksum: "sha256:personal-profile-security-v51-20261012", apply: func(tx *gorm.DB) error { + return tx.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.OAuthState{}, &model.AuthVerification{}, &model.UserAvatar{}) + }}, + {version: 52, name: "user_avatar_presets", checksum: "sha256:user-avatar-presets-v52-20261012", apply: func(tx *gorm.DB) error { + if tx.Migrator().HasColumn(&model.User{}, "AvatarPreset") { + return nil + } + return tx.Migrator().AddColumn(&model.User{}, "AvatarPreset") + }}, + {version: 53, name: "user_login_logs", checksum: "sha256:user-login-logs-v53", apply: func(tx *gorm.DB) error { + return tx.AutoMigrate(&model.LoginLog{}) + }}, } func migratePrefixedIDSequenceReconcile(tx *gorm.DB) error { diff --git a/backend/internal/database/profile_migration_test.go b/backend/internal/database/profile_migration_test.go new file mode 100644 index 000000000..59389883b --- /dev/null +++ b/backend/internal/database/profile_migration_test.go @@ -0,0 +1,141 @@ +package database + +import ( + "bytes" + "testing" + "yingce/backend/internal/model" +) + +func TestProfileMigrationUpgradesLegacyIdentityRows(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatal(err) + } + for _, sql := range []string{ + `CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT, email TEXT, password_hash TEXT, status TEXT)`, + `INSERT INTO users VALUES ('legacy','legacy','legacy@example.com','preserved-hash','active')`, + `CREATE TABLE auth_sessions (id TEXT PRIMARY KEY,user_id TEXT,token_hash TEXT,expires_at DATETIME)`, + `INSERT INTO auth_sessions VALUES ('legacy-session','legacy','preserved-token','2099-01-01')`, + `CREATE TABLE o_auth_states (id TEXT PRIMARY KEY,provider TEXT,state_hash TEXT)`, + `INSERT INTO o_auth_states VALUES ('legacy-state','linuxdo','preserved-state')`, + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + var migration *migration + for i := range schemaMigrations { + if schemaMigrations[i].version == 51 { + migration = &schemaMigrations[i] + } + } + if migration == nil { + t.Fatal("profile migration absent") + } + for i := 0; i < 2; i++ { + if err := db.Transaction(migration.apply); err != nil { + t.Fatal(err) + } + } + for _, field := range []string{"AvatarMode", "AvatarVersion"} { + if !db.Migrator().HasColumn(&model.User{}, field) { + t.Fatalf("missing %s", field) + } + } + if !db.Migrator().HasColumn(&model.AuthSession{}, "ReauthenticatedAt") || !db.Migrator().HasColumn(&model.OAuthState{}, "ReauthUserID") || !db.Migrator().HasColumn(&model.OAuthState{}, "ReauthSessionID") || !db.Migrator().HasColumn(&model.AuthVerification{}, "SessionID") || !db.Migrator().HasColumn(&model.AuthVerification{}, "CredentialHash") || !db.Migrator().HasTable(&model.UserAvatar{}) { + t.Fatal("profile schema incomplete") + } + var user model.User + var session model.AuthSession + var state model.OAuthState + if err := db.First(&user, "id = ?", "legacy").Error; err != nil { + t.Fatal(err) + } + if err := db.First(&session, "id = ?", "legacy-session").Error; err != nil { + t.Fatal(err) + } + if err := db.First(&state, "id = ?", "legacy-state").Error; err != nil { + t.Fatal(err) + } + if user.PasswordHash != "preserved-hash" || user.Email != "legacy@example.com" || session.TokenHash != "preserved-token" || session.ReauthenticatedAt != nil || state.StateHash != "preserved-state" || state.ReauthUserID != "" || state.ReauthSessionID != "" { + t.Fatal("legacy credentials or verification changed") + } + for _, m := range Models() { + if _, ok := m.(*model.UserAvatar); ok { + return + } + } + t.Fatal("avatar missing from Models") +} + +func TestAvatarPresetMigrationUpgradesV51WithoutChangingUsersOrBlobs(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + if err := MigrateSchema(db); err != nil { + t.Fatal(err) + } + // Reconstruct the already applied v51 shape and migration ledger. The new + // migration must add one column without replaying or changing v51 metadata. + if err := db.Migrator().DropColumn(&model.User{}, "AvatarPreset"); err != nil { + t.Fatal(err) + } + if err := db.Delete(&schemaMigration{}, "version = ?", 52).Error; err != nil { + t.Fatal(err) + } + var v51Before schemaMigration + if err := db.First(&v51Before, "version = ?", 51).Error; err != nil { + t.Fatal(err) + } + if v51Before.Name != "personal_profile_security" || v51Before.Checksum != "sha256:personal-profile-security-v51-20261012" { + t.Fatal("existing v51 identity changed") + } + user := model.User{ID: "preset-migration-user", Username: "preset-user", Email: "preset@example.com", DisplayName: "Creator", PasswordHash: "preserved-hash", Status: model.UserStatusActive, AvatarMode: "custom", AvatarVersion: "preserved-avatar-version"} + if err := db.Omit("AvatarPreset").Create(&user).Error; err != nil { + t.Fatal(err) + } + payload := []byte("preserved-avatar-payload") + if err := db.Create(&model.UserAvatar{UserID: user.ID, Data: payload}).Error; err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if err := MigrateSchema(db); err != nil { + t.Fatal(err) + } + } + if !db.Migrator().HasColumn(&model.User{}, "AvatarPreset") { + t.Fatal("missing avatar preset column") + } + var migrated model.User + var avatar model.UserAvatar + var v51After schemaMigration + var v52 schemaMigration + if err := db.First(&migrated, "id = ?", user.ID).Error; err != nil { + t.Fatal(err) + } + if err := db.First(&avatar, "user_id = ?", user.ID).Error; err != nil { + t.Fatal(err) + } + if err := db.First(&v51After, "version = ?", 51).Error; err != nil { + t.Fatal(err) + } + if err := db.First(&v52, "version = ?", 52).Error; err != nil { + t.Fatal(err) + } + if migrated.PasswordHash != user.PasswordHash || migrated.Email != user.Email || migrated.DisplayName != user.DisplayName || migrated.AvatarMode != "custom" || migrated.AvatarVersion != user.AvatarVersion || migrated.AvatarPreset != "" || !bytes.Equal(avatar.Data, payload) { + t.Fatal("v52 migration changed existing profile or custom avatar") + } + if v51After != v51Before || v52.Name != "user_avatar_presets" || v52.Checksum == v51Before.Checksum { + t.Fatal("migration identity was rewritten or new checksum was reused") + } + status, err := ReadSchemaStatus(db) + if err != nil || !status.Ready || status.Current != CurrentSchemaVersion { + t.Fatalf("current schema status: %#v, %v", status, err) + } +} diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index ed801d1d8..852758d63 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -28,7 +28,9 @@ func Models() []any { &model.AgentLesson{}, &model.AgentMemorySetting{}, &model.User{}, + &model.UserAvatar{}, &model.AuthSession{}, + &model.LoginLog{}, &model.UserIdentity{}, &model.OAuthState{}, &model.EmailVerificationCode{}, diff --git a/backend/internal/handler/admin_login_log_routes.go b/backend/internal/handler/admin_login_log_routes.go new file mode 100644 index 000000000..e6aa38450 --- /dev/null +++ b/backend/internal/handler/admin_login_log_routes.go @@ -0,0 +1,64 @@ +package handler + +import ( + "log" + "yingce/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func loginRequestContext(c *gin.Context) service.LoginContext { + return service.LoginContext{IP: c.ClientIP(), UserAgent: c.Request.UserAgent()} +} + +// 补充 JSON 校验和频控等在认证域调用之前被拒绝的请求;不读取或保存原始请求正文。 +func rejectedLoginAudit(svc *service.Service, method string) gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + if c.Writer.Status() < 400 || c.GetBool("login_audit_handled") { + return + } + if err := svc.RecordRejectedLogin(method, loginRequestContext(c), c.Writer.Status()); err != nil { + log.Printf("rejected login audit write failed: method=%s status=%d error=%v", method, c.Writer.Status(), err) + } + } +} + +func registerAdminLoginLogRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/admin/login-logs", func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.RequireAdmin(actor); err != nil { + failService(c, err) + return + } + page, limit, err := parsePaginationQuery(c, 20) + if err != nil { + fail(c, 400, err) + return + } + query := service.LoginLogQuery{Keyword: c.Query("keyword"), Event: c.Query("event"), Method: c.Query("method"), Result: c.Query("result"), IP: c.Query("ip"), From: c.Query("from"), To: c.Query("to")} + result, err := svc.AdminLoginLogs(actor, query, page, limit) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + r.GET("/admin/login-logs/:id", func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + event, err := svc.AdminLoginLog(actor, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"log": event}) + }) +} diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index e65203e6e..021a5776b 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -22,6 +22,7 @@ import ( func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) { registerChannelOrderRoutes(r, svc) registerNotificationRoutes(r, svc) + registerAuthProfileRoutes(r, svc) r.GET("/auth/settings", func(c *gin.Context) { settings, err := svc.PublicAuthSettings() if err != nil { @@ -41,6 +42,7 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) { if !available || !enforceRateLimit(c, "register:"+c.ClientIP(), policy.Request.RegisterPerHour, time.Hour) { return } + req.LoginContext = loginRequestContext(c) result, err := svc.Register(req) if err != nil { failService(c, err) @@ -113,7 +115,7 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"reset": true}) }) - r.POST("/auth/login", func(c *gin.Context) { + r.POST("/auth/login", rejectedLoginAudit(svc, "password"), func(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) var req service.LoginRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -132,6 +134,8 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) { if !enforceRateLimit(c, "login:"+c.ClientIP()+":"+subject, policy.Request.LoginAccountPerTenMinutes, 10*time.Minute) { return } + req.LoginContext = loginRequestContext(c) + c.Set("login_audit_handled", true) result, err := svc.Login(req) if err != nil { failService(c, err) @@ -152,9 +156,12 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) { } c.Redirect(http.StatusFound, target) }) - r.GET("/auth/linuxdo/callback", linuxDOCallbackHandler(svc)) + r.GET("/auth/linuxdo/callback", rejectedLoginAudit(svc, "linuxdo"), linuxDOCallbackHandler(svc)) r.POST("/auth/logout", func(c *gin.Context) { - _ = svc.Logout(sessionCookie(c)) + if err := svc.Logout(sessionCookie(c), loginRequestContext(c)); err != nil { + failService(c, err) + return + } clearSessionCookie(c) ok(c, gin.H{"ok": true}) }) @@ -207,7 +214,7 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) { // 兼容已在 Linux.do OAuth 应用中登记的传统回调地址,处理逻辑与 /api/auth/linuxdo/callback 完全一致。 func RegisterOAuthCallbackRoutes(r gin.IRoutes, svc *service.Service) { - r.GET("/oauth/linuxdo/callback", linuxDOCallbackHandler(svc)) + r.GET("/oauth/linuxdo/callback", rejectedLoginAudit(svc, "linuxdo"), linuxDOCallbackHandler(svc)) } func linuxDOCallbackHandler(svc *service.Service) gin.HandlerFunc { @@ -215,12 +222,27 @@ func linuxDOCallbackHandler(svc *service.Service) gin.HandlerFunc { if !enforceRateLimit(c, "linuxdo-callback:"+c.ClientIP(), 30, 10*time.Minute) { return } - result, err := svc.CompleteLinuxDOLogin(c.Query("state"), c.Query("code")) + reauthentication, stateErr := svc.LinuxDOReauthenticationState(c.Query("state")) + if stateErr != nil { + if err := svc.RecordRejectedLogin("linuxdo", loginRequestContext(c), 400); err != nil { + log.Printf("OAuth rejected login audit write failed: error=%v", err) + } + c.Redirect(http.StatusFound, "/login?oauth_error="+url.QueryEscape("无法读取身份验证状态,请重试")) + return + } + c.Set("login_audit_handled", true) + result, err := svc.CompleteLinuxDOLogin(c.Query("state"), c.Query("code"), sessionCookie(c), loginRequestContext(c)) if err != nil { + if reauthentication { + c.Redirect(http.StatusFound, "/settings?section=profile&reauth=error") + return + } c.Redirect(http.StatusFound, "/login?oauth_error="+url.QueryEscape(err.Error())) return } - setSessionCookie(c, result.Session.Session, result.Session.MaxAgeSecs) + if !result.Reauthenticated { + setSessionCookie(c, result.Session.Session, result.Session.MaxAgeSecs) + } c.Redirect(http.StatusFound, result.Next) } } @@ -230,6 +252,7 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) { registerAdminChannelRoutes(r, svc) registerAdminSettingRoutes(r, svc) registerAdminAPILogRoutes(r, svc) + registerAdminLoginLogRoutes(r, svc) } func RegisterSystemProxyRoutes(r *gin.RouterGroup, svc *service.Service) { diff --git a/backend/internal/handler/auth_profile.go b/backend/internal/handler/auth_profile.go new file mode 100644 index 000000000..985d001b7 --- /dev/null +++ b/backend/internal/handler/auth_profile.go @@ -0,0 +1,165 @@ +package handler + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "yingce/backend/internal/model" + "yingce/backend/internal/service" +) + +func registerAuthProfileRoutes(r *gin.RouterGroup, svc *service.Service) { + profile := r.Group("/auth/profile") + profile.Use(func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + c.Abort() + return + } + c.Set("profileActor", actor) + }) + actor := func(c *gin.Context) *model.User { return c.MustGet("profileActor").(*model.User) } + securityWrite := func(c *gin.Context) bool { + policy, available := loadRuntimePolicy(c, svc) + return available && enforceRateLimit(c, "profile-security-ip:"+c.ClientIP(), policy.Request.LoginIPPerTenMinutes, 10*time.Minute) && + enforceRateLimit(c, "profile-security-user:"+actor(c).ID, policy.Request.LoginAccountPerTenMinutes, 10*time.Minute) + } + profile.GET("", func(c *gin.Context) { + result, err := svc.UserProfile(actor(c), sessionCookie(c)) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + profile.PUT("/avatar", func(c *gin.Context) { + policy, available := loadRuntimePolicy(c, svc) + if !available || !enforceRateLimit(c, "profile-avatar:"+actor(c).ID, policy.Request.ResourceUploadPerMinute, time.Minute) { + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, (5<<20)+(64<<10)) + file, err := c.FormFile("file") + if c.Request.MultipartForm != nil { + defer c.Request.MultipartForm.RemoveAll() + } + if err != nil { + failService(c, service.BadAuthRequest("请选择不超过 5 MiB 的 PNG 或 JPEG 头像")) + return + } + result, err := svc.UpdateProfileAvatar(actor(c), file) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"user": result}) + }) + profile.DELETE("/avatar", func(c *gin.Context) { + if !enforceRateLimit(c, "profile-avatar-default:"+actor(c).ID, 30, time.Minute) { + return + } + result, err := svc.UseDefaultProfileAvatar(actor(c)) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"user": result}) + }) + profile.GET("/avatar/:userId", func(c *gin.Context) { + data, err := svc.ProfileAvatarContent(actor(c), c.Param("userId")) + if err != nil { + failService(c, err) + return + } + c.Header("Cache-Control", "private, no-store") + c.Header("X-Content-Type-Options", "nosniff") + c.Data(http.StatusOK, "image/png", data) + }) + profile.POST("/avatar/default", func(c *gin.Context) { + if !enforceRateLimit(c, "profile-avatar-default:"+actor(c).ID, 30, time.Minute) { + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 2<<10) + var req struct { + Preset string `json:"preset"` + } + if err := c.ShouldBindJSON(&req); err != nil { + failService(c, service.BadAuthRequest("系统头像选择参数无效")) + return + } + result, err := svc.SelectDefaultProfileAvatar(actor(c), req.Preset) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"user": result}) + }) + profile.POST("/password", func(c *gin.Context) { + if !securityWrite(c) { + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10) + var req service.ProfilePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + failService(c, service.BadAuthRequest("密码修改参数无效")) + return + } + if err := svc.ChangeProfilePassword(actor(c), sessionCookie(c), req); err != nil { + failService(c, err) + return + } + clearSessionCookie(c) + ok(c, gin.H{"changed": true}) + }) + profile.POST("/email-code", func(c *gin.Context) { + if !securityWrite(c) { + return + } + policy, available := loadRuntimePolicy(c, svc) + if !available || !enforceRateLimit(c, "profile-email-code:"+actor(c).ID, policy.Request.EmailCodePerHour, time.Hour) { + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10) + var req service.ProfileEmailCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + failService(c, service.BadAuthRequest("邮箱验证参数无效")) + return + } + result, err := svc.SendProfileEmailCode(c.Request.Context(), actor(c), sessionCookie(c), req) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + profile.POST("/email", func(c *gin.Context) { + if !securityWrite(c) { + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10) + var req service.ProfileEmailChangeRequest + if err := c.ShouldBindJSON(&req); err != nil { + failService(c, service.BadAuthRequest("邮箱修改参数无效")) + return + } + result, err := svc.ChangeProfileEmail(actor(c), sessionCookie(c), req) + if err != nil { + failService(c, err) + return + } + clearSessionCookie(c) + ok(c, result) + }) + profile.GET("/linuxdo/start", func(c *gin.Context) { + if !enforceRateLimit(c, "profile-linuxdo-reauth:"+actor(c).ID, 10, 10*time.Minute) { + return + } + target, err := svc.BeginLinuxDOReauthentication(actor(c), sessionCookie(c)) + if err != nil { + failService(c, err) + return + } + c.Redirect(http.StatusFound, target) + }) +} diff --git a/backend/internal/handler/auth_profile_test.go b/backend/internal/handler/auth_profile_test.go new file mode 100644 index 000000000..a8a8a9033 --- /dev/null +++ b/backend/internal/handler/auth_profile_test.go @@ -0,0 +1,220 @@ +package handler + +import ( + "bytes" + "encoding/json" + "image" + "image/png" + "mime/multipart" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "yingce/backend/internal/auth" + "yingce/backend/internal/database" + "yingce/backend/internal/model" + "yingce/backend/internal/repository" + "yingce/backend/internal/service" +) + +func profileHandlerFixture(t *testing.T) (*gin.Engine, *gorm.DB, string) { + t.Helper() + gin.SetMode(gin.TestMode) + dir := t.TempDir() + db, err := gorm.Open(sqlite.Open(filepath.Join(dir, "fixture.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := database.MigrateSchema(db); err != nil { + t.Fatal(err) + } + hash, err := auth.HashPassword("old-password") + if err != nil { + t.Fatal(err) + } + user := model.User{ID: "profile-user", Username: "profile-user", DisplayName: "Creator", Role: model.UserRoleUser, Status: model.UserStatusActive, PasswordHash: hash} + if err := db.Create(&user).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&[]model.AuthSession{ + {ID: "profile-session", UserID: user.ID, TokenHash: auth.HashToken("fixture-token"), ExpiresAt: time.Now().Add(time.Hour)}, + {ID: "profile-other-session", UserID: user.ID, TokenHash: auth.HashToken("other-token"), ExpiresAt: time.Now().Add(time.Hour)}, + }).Error; err != nil { + t.Fatal(err) + } + svc := service.New(repository.New(db), dir) + ConfigureRuntime(svc) + t.Cleanup(func() { ConfigureRuntime(nil); svc.Close(); sqlDB, _ := db.DB(); _ = sqlDB.Close() }) + router := gin.New() + RegisterAuthRoutes(router.Group("/api"), svc) + return router, db, "profile-session.fixture-token" +} + +func profileRequest(router http.Handler, method, path, body, cookie string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + if cookie != "" { + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: cookie}) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func TestProfileRoutesRejectAnonymousBeforeMutation(t *testing.T) { + router, _, _ := profileHandlerFixture(t) + for _, route := range []struct{ method, path string }{ + {"GET", "/api/auth/profile"}, {"PUT", "/api/auth/profile/avatar"}, + {"DELETE", "/api/auth/profile/avatar"}, {"GET", "/api/auth/profile/avatar/profile-user"}, + {"POST", "/api/auth/profile/avatar/default"}, + {"POST", "/api/auth/profile/password"}, {"POST", "/api/auth/profile/email-code"}, + {"POST", "/api/auth/profile/email"}, {"GET", "/api/auth/profile/linuxdo/start"}, + } { + rec := profileRequest(router, route.method, route.path, "{}", "") + if rec.Code != http.StatusUnauthorized { + t.Errorf("%s %s returned %d", route.method, route.path, rec.Code) + } + } +} + +func TestProfilePasswordHTTPRevokesSessionsAndClearsCookie(t *testing.T) { + router, db, cookie := profileHandlerFixture(t) + wrong := profileRequest(router, "POST", "/api/auth/profile/password", `{"currentPassword":"wrong-password","newPassword":"new-password"}`, cookie) + if wrong.Code != 401 { + t.Fatalf("wrong password status %d: %s", wrong.Code, wrong.Body.String()) + } + var count int64 + db.Model(&model.AuthSession{}).Count(&count) + if count != 2 { + t.Fatalf("failed change revoked sessions: %d", count) + } + rec := profileRequest(router, "POST", "/api/auth/profile/password", `{"currentPassword":"old-password","newPassword":"new-password"}`, cookie) + if rec.Code != 200 { + t.Fatalf("change status %d: %s", rec.Code, rec.Body.String()) + } + cleared := false + for _, c := range rec.Result().Cookies() { + if c.Name == auth.SessionCookieName && c.MaxAge < 0 { + cleared = true + } + } + if !cleared { + t.Fatal("successful change did not clear login cookie") + } + var user model.User + db.First(&user, "id = ?", "profile-user") + if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte("new-password")) != nil { + t.Fatal("new password not persisted") + } + db.Model(&model.AuthSession{}).Count(&count) + if count != 0 { + t.Fatalf("sessions remain after change: %d", count) + } + if status := profileRequest(router, "GET", "/api/auth/profile", "", cookie).Code; status != 401 { + t.Fatalf("old cookie still accepted: %d", status) + } +} + +func TestProfilePasswordRejectsOversizedUTF8WithoutRevokingSessions(t *testing.T) { + router, db, cookie := profileHandlerFixture(t) + body, err := json.Marshal(map[string]string{"currentPassword": "old-password", "newPassword": strings.Repeat("密", 25)}) + if err != nil { + t.Fatal(err) + } + rec := profileRequest(router, "POST", "/api/auth/profile/password", string(body), cookie) + if rec.Code != http.StatusBadRequest { + t.Fatalf("oversized UTF-8 password status %d, want 400", rec.Code) + } + var count int64 + if err := db.Model(&model.AuthSession{}).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 2 { + t.Fatalf("invalid password revoked sessions: %d", count) + } +} + +func TestProfileDefaultAvatarSelectionPersistsAndRejectsUnknownPreset(t *testing.T) { + router, db, cookie := profileHandlerFixture(t) + invalid := profileRequest(router, "POST", "/api/auth/profile/avatar/default", `{"preset":"unsupported"}`, cookie) + if invalid.Code != 400 { + t.Fatalf("unknown preset status %d", invalid.Code) + } + valid := profileRequest(router, "POST", "/api/auth/profile/avatar/default", `{"preset":"cat"}`, cookie) + if valid.Code != 200 { + t.Fatalf("selection status %d: %s", valid.Code, valid.Body.String()) + } + var user model.User + if err := db.First(&user, "id = ?", "profile-user").Error; err != nil { + t.Fatal(err) + } + if user.AvatarMode != "default" || user.AvatarPreset != "cat" || user.AvatarVersion != "" { + t.Fatal("selected default was not persisted") + } + reloaded := profileRequest(router, "GET", "/api/auth/profile", "", cookie) + if reloaded.Code != 200 || !strings.Contains(reloaded.Body.String(), `"avatarPreset":"cat"`) { + t.Fatalf("reloaded profile lost selection: %d", reloaded.Code) + } +} + +func TestProfileAvatarHTTPPersistsServesAndRestoresDefault(t *testing.T) { + router, db, cookie := profileHandlerFixture(t) + var body bytes.Buffer + form := multipart.NewWriter(&body) + part, err := form.CreateFormFile("file", "avatar.png") + if err != nil { + t.Fatal(err) + } + if err := png.Encode(part, image.NewNRGBA(image.Rect(0, 0, 40, 20))); err != nil { + t.Fatal(err) + } + if err := form.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("PUT", "/api/auth/profile/avatar", &body) + req.Header.Set("Content-Type", form.FormDataContentType()) + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: cookie}) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("upload status %d: %s", rec.Code, rec.Body.String()) + } + var envelope struct { + Data struct { + User struct { + AvatarURL string `json:"avatarUrl"` + } `json:"user"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(envelope.Data.User.AvatarURL, "/api/auth/profile/avatar/profile-user?v=") { + t.Fatalf("missing versioned avatar URL: %s", envelope.Data.User.AvatarURL) + } + content := profileRequest(router, "GET", envelope.Data.User.AvatarURL, "", cookie) + if content.Code != 200 || content.Header().Get("Content-Type") != "image/png" || content.Header().Get("Cache-Control") != "private, no-store" { + t.Fatalf("avatar response %d %v", content.Code, content.Header()) + } + if foreign := profileRequest(router, "GET", "/api/auth/profile/avatar/other-user", "", cookie); foreign.Code != 403 { + t.Fatalf("foreign avatar status %d", foreign.Code) + } + reset := profileRequest(router, "DELETE", "/api/auth/profile/avatar", "", cookie) + if reset.Code != 200 { + t.Fatalf("reset avatar status %d: %s", reset.Code, reset.Body.String()) + } + var user model.User + db.First(&user, "id = ?", "profile-user") + if user.AvatarMode != "default" || user.AvatarVersion != "" { + t.Fatalf("default not persisted: %q", user.AvatarMode) + } +} diff --git a/backend/internal/handler/login_identity_repro_test.go b/backend/internal/handler/login_identity_repro_test.go index b6a05ef78..24d032528 100644 --- a/backend/internal/handler/login_identity_repro_test.go +++ b/backend/internal/handler/login_identity_repro_test.go @@ -37,7 +37,7 @@ func testLoginIdentityAttempts(t *testing.T, accounts []string) []string { if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.User{}, &model.SystemSetting{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.LoginLog{}, &model.SystemSetting{}); err != nil { t.Fatal(err) } sqlDB, _ := db.DB() diff --git a/backend/internal/handler/login_logs_test.go b/backend/internal/handler/login_logs_test.go new file mode 100644 index 000000000..a97059029 --- /dev/null +++ b/backend/internal/handler/login_logs_test.go @@ -0,0 +1,178 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + "time" + + "yingce/backend/internal/model" + "yingce/backend/internal/repository" + "yingce/backend/internal/service" +) + +func TestLoginLogsPasswordAndLogoutLifecycle(t *testing.T) { + router, db, _ := profileHandlerFixture(t) + rec := profileRequest(router, "POST", "/api/auth/login", `{"username":"profile-user","password":"old-password"}`, "") + if rec.Code != 200 { + t.Fatalf("login: %d %s", rec.Code, rec.Body.String()) + } + var cookie string + for _, c := range rec.Result().Cookies() { + if c.Name == service.SessionCookieName { + cookie = c.Value + } + } + if cookie == "" { + t.Fatal("missing session") + } + var logs []map[string]any + if err := db.Table("login_logs").Order("created_at").Find(&logs).Error; err != nil { + t.Fatal(err) + } + if len(logs) != 1 || logs[0]["event"] != "login" || logs[0]["result"] != "success" || logs[0]["method"] != "password" { + t.Fatalf("login logs: %#v", logs) + } + for i := 0; i < 2; i++ { + rec = profileRequest(router, "POST", "/api/auth/logout", "", cookie) + if rec.Code != 200 { + t.Fatalf("logout: %d %s", rec.Code, rec.Body.String()) + } + } + logs = nil + if err := db.Table("login_logs").Order("created_at").Find(&logs).Error; err != nil { + t.Fatal(err) + } + if len(logs) != 2 || logs[1]["event"] != "logout" || logs[0]["session_ref"] != logs[1]["session_ref"] { + t.Fatalf("logout logs: %#v", logs) + } +} + +func TestLoginLogsForgedAndExpiredLogoutDoNotRevokeOrLog(t *testing.T) { + router, db, cookie := profileHandlerFixture(t) + for _, value := range []string{"profile-session.forged-token", "", "invalid"} { + rec := profileRequest(router, "POST", "/api/auth/logout", "", value) + if rec.Code != 200 { + t.Fatalf("invalid logout: %d", rec.Code) + } + } + var count int64 + db.Model(&model.AuthSession{}).Where("id = ?", "profile-session").Count(&count) + if count != 1 { + t.Fatal("forged token revoked a real session") + } + db.Model(&model.LoginLog{}).Count(&count) + if count != 0 { + t.Fatal("invalid logout generated success logs") + } + db.Model(&model.AuthSession{}).Where("id = ?", "profile-session").Update("expires_at", time.Now().Add(-time.Hour)) + profileRequest(router, "POST", "/api/auth/logout", "", cookie) + db.Model(&model.LoginLog{}).Count(&count) + if count != 0 { + t.Fatal("expired session logged as active logout") + } +} + +func TestLoginLogsWriteFailureRollsBackSessionChanges(t *testing.T) { + router, db, cookie := profileHandlerFixture(t) + if err := db.Exec(`CREATE TRIGGER reject_success_login_log BEFORE INSERT ON login_logs WHEN NEW.result = 'success' BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END`).Error; err != nil { + t.Fatal(err) + } + for _, req := range []struct{ path, body, cookie string }{ + {"/api/auth/login", `{"username":"profile-user","password":"old-password"}`, ""}, + {"/api/auth/logout", "", cookie}, + } { + rec := profileRequest(router, "POST", req.path, req.body, req.cookie) + if rec.Code != 500 { + t.Fatalf("write failure %s: %d %s", req.path, rec.Code, rec.Body.String()) + } + if len(rec.Result().Cookies()) != 0 { + t.Fatal("failed auth changed session cookie") + } + } + var count int64 + if err := db.Model(&model.AuthSession{}).Count(&count).Error; err != nil || count != 2 { + t.Fatalf("session transaction: %d %v", count, err) + } + if err := db.Model(&model.LoginLog{}).Where("result = ?", "success").Count(&count).Error; err != nil || count != 0 { + t.Fatal("failed operation logged as success") + } +} + +func TestLoginLogsRejectedJSONAndSessionRefresh(t *testing.T) { + router, db, cookie := profileHandlerFixture(t) + bad := profileRequest(router, "POST", "/api/auth/login", "{broken-json", "") + if bad.Code != 400 { + t.Fatalf("bad JSON: %d", bad.Code) + } + for i := 0; i < 2; i++ { + profileRequest(router, "GET", "/api/auth/session", "", cookie) + } + var events []model.LoginLog + if err := db.Find(&events).Error; err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0].Result != "failed" || events[0].Method != "password" || events[0].Account != "" { + t.Fatalf("rejection/refresh: %#v", events) + } +} + +func TestLoginLogsAdminAccessAndFailedLogin(t *testing.T) { + router, db, cookie := profileHandlerFixture(t) + svc := service.New(repository.New(db), t.TempDir()) + t.Cleanup(func() { _ = svc.Close() }) + RegisterAdminRoutes(router.Group("/api"), svc) + for _, path := range []string{"/api/admin/login-logs", "/api/admin/login-logs/missing"} { + if rec := profileRequest(router, "GET", path, "", ""); rec.Code != 401 { + t.Fatalf("anonymous %s: %d", path, rec.Code) + } + if rec := profileRequest(router, "GET", path, "", cookie); rec.Code != 403 { + t.Fatalf("member %s: %d", path, rec.Code) + } + } + rec := profileRequest(router, "POST", "/api/auth/login", `{"username":"unknown@example.com","password":"never-store-this"}`, "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("failed login: %d", rec.Code) + } + if err := db.Model(&model.User{}).Where("id = ?", "profile-user").Update("role", model.UserRoleAdmin).Error; err != nil { + t.Fatal(err) + } + rec = profileRequest(router, "GET", "/api/admin/login-logs?result=failed&method=password", "", cookie) + if rec.Code != 200 { + t.Fatalf("admin: %d %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "never-store-this") || strings.Contains(rec.Body.String(), "unknown@example.com") { + t.Fatal("sensitive login input exposed") + } + var body struct { + Data struct { + Logs []struct { + ID string `json:"id"` + UserID *string `json:"userId"` + } + Total int + } + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Data.Total != 1 || len(body.Data.Logs) != 1 || body.Data.Logs[0].UserID != nil { + t.Fatalf("failed page: %s", rec.Body.String()) + } + detail := profileRequest(router, "GET", "/api/admin/login-logs/"+body.Data.Logs[0].ID, "", cookie) + if detail.Code != 200 { + t.Fatalf("detail: %d", detail.Code) + } + if bad := profileRequest(router, "GET", "/api/admin/login-logs?event=invalid", "", cookie); bad.Code != 400 { + t.Fatalf("invalid filter: %d", bad.Code) + } + for _, query := range []string{"method=invalid", "result=invalid", "ip=invalid", "from=invalid", "from=2026-01-02T00:00:00Z&to=2026-01-01T00:00:00Z", "page=-1"} { + if bad := profileRequest(router, "GET", "/api/admin/login-logs?"+query, "", cookie); bad.Code != 400 { + t.Fatalf("invalid query %s: %d", query, bad.Code) + } + } + if missing := profileRequest(router, "GET", "/api/admin/login-logs/missing", "", cookie); missing.Code != 404 { + t.Fatalf("missing detail: %d", missing.Code) + } +} diff --git a/backend/internal/handler/notifications.go b/backend/internal/handler/notifications.go index 1874cf656..a39488069 100644 --- a/backend/internal/handler/notifications.go +++ b/backend/internal/handler/notifications.go @@ -39,7 +39,7 @@ func registerNotificationRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, result) }) - r.POST("/auth/verification/login", func(c *gin.Context) { + r.POST("/auth/verification/login", rejectedLoginAudit(svc, "verification"), func(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10) var req service.VerificationConfirm if err := c.ShouldBindJSON(&req); err != nil { @@ -49,6 +49,8 @@ func registerNotificationRoutes(r *gin.RouterGroup, svc *service.Service) { if !enforceRateLimit(c, "verification-login:"+c.ClientIP(), 30, time.Hour) { return } + req.LoginContext = loginRequestContext(c) + c.Set("login_audit_handled", true) result, err := svc.LoginVerification(req) if err != nil { failService(c, err) diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index 33d5d091e..bdb90dfb7 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -27,6 +27,66 @@ tags: - name: admin - name: skills paths: + /admin/login-logs: + get: + tags: [admin] + summary: 管理员分页查询用户登录登出日志 + description: 按时间倒序,保留用户快照;失败账号脱敏,无密码、验证码或会话凭据。 + parameters: + - {in: query, name: keyword, schema: {type: string, maxLength: 160}, description: 用户名、昵称、脱敏账号或用户 ID} + - {in: query, name: event, schema: {type: string, enum: [login, logout]}} + - {in: query, name: method, schema: {type: string, enum: [password, email, sms, linuxdo, verification, unknown]}} + - {in: query, name: result, schema: {type: string, enum: [success, failed]}} + - {in: query, name: ip, schema: {type: string}, description: 精确匹配 IPv4 或 IPv6} + - {in: query, name: from, schema: {type: string, format: date-time}, description: 包含开始时刻,RFC3339} + - {in: query, name: to, schema: {type: string, format: date-time}, description: 包含结束时刻,RFC3339} + - {in: query, name: page, schema: {type: integer, minimum: 1, default: 1}} + - {in: query, name: pageSize, schema: {type: integer, minimum: 1, default: 20}} + responses: + "200": + description: 成功信封 data={logs,total,page,pageSize} + content: + application/json: + schema: + type: object + properties: + code: {type: integer, enum: [0]} + msg: {type: string} + data: + type: object + properties: + logs: {type: array, items: {$ref: "#/components/schemas/LoginLog"}} + total: {type: integer} + page: {type: integer} + pageSize: {type: integer} + "400": {$ref: "#/components/responses/Error"} + "401": {$ref: "#/components/responses/Error"} + "403": {$ref: "#/components/responses/Error"} + "500": {$ref: "#/components/responses/Error"} + /admin/login-logs/{id}: + get: + tags: [admin] + summary: 管理员读取单条登录日志详情 + parameters: + - {in: path, name: id, required: true, schema: {type: string}} + responses: + "200": + description: 成功信封 data.log + content: + application/json: + schema: + type: object + properties: + code: {type: integer, enum: [0]} + msg: {type: string} + data: + type: object + properties: + log: {$ref: "#/components/schemas/LoginLog"} + "401": {$ref: "#/components/responses/Error"} + "403": {$ref: "#/components/responses/Error"} + "404": {$ref: "#/components/responses/Error"} + "500": {$ref: "#/components/responses/Error"} /admin/settings/appearance/live2d: post: tags: [admin] @@ -1056,6 +1116,202 @@ paths: $ref: "#/components/responses/Error" "404": $ref: "#/components/responses/Error" + /auth/profile: + get: + tags: [auth] + summary: 当前用户个人信息及安全能力 + description: 返回 user、hasPassword、emailEnabled、linuxdoEnabled 和可选 reauthenticatedUntil;个人头像选择覆盖第三方头像。内部密码散列与头像版本字段不返回。 + responses: + "200": + $ref: "#/components/responses/Envelope" + "401": + $ref: "#/components/responses/Error" + "403": + $ref: "#/components/responses/Error" + /auth/profile/avatar: + put: + tags: [auth] + summary: 保存当前用户头像 + description: 真实 PNG/JPEG,最大 5 MiB、1600 万像素,居中裁剪并缩小到最大512像素PNG。响应 data.user 包含更新后的头像地址。 + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: { type: string, format: binary } + responses: + "200": + $ref: "#/components/responses/Envelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" + "429": + $ref: "#/components/responses/Error" + delete: + tags: [auth] + summary: 使用系统默认头像 + description: 清除本用户头像并保存 default 选择,后续第三方登录仍使用系统头像。返回 data.user。 + responses: + "200": + $ref: "#/components/responses/Envelope" + "401": + $ref: "#/components/responses/Error" + "429": + $ref: "#/components/responses/Error" + /auth/profile/avatar/default: + post: + tags: [auth] + summary: 选择系统头像 + description: 保存选择并原子清除自定义图片,返回 data.user。系统头像由共享组件渲染;不接受客户端 URL 或任意图标标识。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [preset] + properties: + preset: + type: string + enum: [person, director, camera, cat, rabbit, robot, rocket, sparkles] + responses: + "200": + $ref: "#/components/responses/Envelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" + "429": + $ref: "#/components/responses/Error" + /auth/profile/avatar/{userId}: + get: + tags: [auth] + summary: 读取当前账号头像 PNG + parameters: + - name: userId + in: path + required: true + schema: { type: string } + - name: v + in: query + schema: { type: string } + responses: + "200": + description: 仅当前用户可读,private no-store + content: + image/png: + schema: { type: string, format: binary } + "401": + $ref: "#/components/responses/Error" + "403": + $ref: "#/components/responses/Error" + "404": + $ref: "#/components/responses/Error" + /auth/profile/password: + post: + tags: [auth] + summary: 修改或设置本地密码并撤销所有会话 + description: 已有密码必须校验 currentPassword;纯第三方账号须在当前会话完成5分钟内的Linux.do重新授权核验。成功返回 data.changed=true 并清除会话 Cookie。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [newPassword] + properties: + currentPassword: { type: string, format: password } + newPassword: + type: string + format: password + minLength: 8 + maxLength: 72 + description: 至少8个Unicode字符,UTF-8字节长度不超过72 + responses: + "200": + $ref: "#/components/responses/Envelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" + "429": + $ref: "#/components/responses/Error" + /auth/profile/email-code: + post: + tags: [auth] + summary: 向新邮箱发送专用验证码 + description: 验证当前密码或当前会话最近的Linux.do核验;新地址未被占用且不同于旧地址。返回 ticket、expiresIn=600、retryAfter=60;票据绑定用户/会话/凭据,仅profile_email用途有效。发送失败显式报错。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: { type: string, format: email } + currentPassword: { type: string, format: password } + responses: + "200": + $ref: "#/components/responses/Envelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" + "403": + $ref: "#/components/responses/Error" + "429": + $ref: "#/components/responses/Error" + "503": + $ref: "#/components/responses/Error" + /auth/profile/email: + post: + tags: [auth] + summary: 验证新邮箱并更新绑定 + description: 原子校验邮箱唯一性、消费验证码、更新已验证邮箱并撤销全部会话;清除Cookie。返回changed=true及notificationStatus(sent/failed/not_needed),failed仅表示已更新后的原邮箱通知失败。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [ticket, emailCode] + properties: + ticket: { type: string } + emailCode: { type: string, pattern: "^[0-9]{6}$" } + currentPassword: { type: string, format: password } + responses: + "200": + $ref: "#/components/responses/Envelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Error" + "403": + $ref: "#/components/responses/Error" + "429": + $ref: "#/components/responses/Error" + /auth/profile/linuxdo/start: + get: + tags: [auth] + summary: 发起当前账号Linux.do身份重新核验 + description: OAuth state绑定当前账号和当前会话,回调只接受原绑定subject;不创建账号或新登录会话。授权成功返回/settings?section=profile&reauth=success,失败返回reauth=error。 + responses: + "302": + description: 跳转到第三方授权页面 + headers: + Location: + schema: { type: string, format: uri } + "401": + $ref: "#/components/responses/Error" + "403": + $ref: "#/components/responses/Error" + "429": + $ref: "#/components/responses/Error" /auth/session: get: tags: [auth] @@ -1300,6 +1556,24 @@ components: in: cookie name: open_ai_canvas_session schemas: + LoginLog: + type: object + properties: + id: {type: string} + userId: {type: string, nullable: true} + username: {type: string, description: 事件时用户名快照} + displayName: {type: string} + account: {type: string, description: 用户名或脱敏的尝试账号} + event: {type: string, enum: [login, logout]} + method: {type: string, enum: [password, email, sms, linuxdo, verification, unknown]} + source: {type: string, enum: [login, registration, logout]} + result: {type: string, enum: [success, failed]} + ip: {type: string} + userAgent: {type: string, maxLength: 512, description: 客户端自报信息,不作为身份判断依据} + sessionRef: {type: string, description: 会话 ID 的单向哈希,仅关联历史事件,不含 Token} + reason: {type: string} + message: {type: string} + createdAt: {type: string, format: date-time} ToolMutation: type: object required: [type, label, prompt] diff --git a/backend/internal/model/models_identity.go b/backend/internal/model/models_identity.go index 9b5aae32c..b682cf487 100644 --- a/backend/internal/model/models_identity.go +++ b/backend/internal/model/models_identity.go @@ -10,6 +10,9 @@ type User struct { EmailVerifiedAt *time.Time `json:"emailVerifiedAt,omitempty"` PhoneVerifiedAt *time.Time `json:"phoneVerifiedAt,omitempty"` DisplayName string `json:"displayName" gorm:"size:80"` + AvatarMode string `json:"avatarMode" gorm:"size:16"` + AvatarPreset string `json:"avatarPreset,omitempty" gorm:"size:16"` + AvatarVersion string `json:"-" gorm:"size:36"` Role UserRole `json:"role" gorm:"index;size:24"` Status UserStatus `json:"status" gorm:"index;size:24"` PasswordHash string `json:"-"` @@ -19,12 +22,13 @@ type User struct { } type AuthSession struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36"` - TokenHash string `json:"-"` - ExpiresAt time.Time `json:"expiresAt" gorm:"index"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ReauthenticatedAt *time.Time `json:"-"` + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + TokenHash string `json:"-"` + ExpiresAt time.Time `json:"expiresAt" gorm:"index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type UserIdentity struct { @@ -39,15 +43,17 @@ type UserIdentity struct { } type OAuthState struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - Provider string `json:"provider" gorm:"index;size:32"` - StateHash string `json:"-" gorm:"uniqueIndex;size:64"` - CodeVerifier string `json:"-" gorm:"size:160"` - NextPath string `json:"nextPath"` - AcceptedTerms bool `json:"acceptedTerms" gorm:"not null;default:false"` - ExpiresAt time.Time `json:"expiresAt" gorm:"index"` - UsedAt *time.Time `json:"usedAt" gorm:"index"` - CreatedAt time.Time `json:"createdAt"` + ReauthUserID string `json:"-" gorm:"size:36"` + ReauthSessionID string `json:"-" gorm:"size:36"` + ID string `json:"id" gorm:"primaryKey;size:36"` + Provider string `json:"provider" gorm:"index;size:32"` + StateHash string `json:"-" gorm:"uniqueIndex;size:64"` + CodeVerifier string `json:"-" gorm:"size:160"` + NextPath string `json:"nextPath"` + AcceptedTerms bool `json:"acceptedTerms" gorm:"not null;default:false"` + ExpiresAt time.Time `json:"expiresAt" gorm:"index"` + UsedAt *time.Time `json:"usedAt" gorm:"index"` + CreatedAt time.Time `json:"createdAt"` } type EmailVerificationCode struct { diff --git a/backend/internal/model/models_login_log.go b/backend/internal/model/models_login_log.go new file mode 100644 index 000000000..61d817a30 --- /dev/null +++ b/backend/internal/model/models_login_log.go @@ -0,0 +1,22 @@ +package model + +import "time" + +// LoginLog 保存事件时的用户快照,不随账号改名或会话清理改写历史。 +type LoginLog struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID *string `json:"userId" gorm:"index;size:36"` + Username string `json:"username" gorm:"size:80"` + DisplayName string `json:"displayName" gorm:"size:80"` + Account string `json:"account" gorm:"size:160"` + Event string `json:"event" gorm:"index;size:16"` + Method string `json:"method" gorm:"index;size:24"` + Source string `json:"source" gorm:"size:24"` + Result string `json:"result" gorm:"index;size:16"` + IP string `json:"ip" gorm:"index;size:64"` + UserAgent string `json:"userAgent" gorm:"size:512"` + SessionRef string `json:"sessionRef" gorm:"index;size:64"` + Reason string `json:"reason" gorm:"size:64"` + Message string `json:"message" gorm:"size:500"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` +} diff --git a/backend/internal/model/models_notifications.go b/backend/internal/model/models_notifications.go index cf25aa168..3956a8105 100644 --- a/backend/internal/model/models_notifications.go +++ b/backend/internal/model/models_notifications.go @@ -43,21 +43,23 @@ type SMSRecord struct { // Verification tickets are immutable, short-lived and purpose/target bound. // Only code HMACs and the hash of the high-entropy client ticket are persisted. type AuthVerification struct { - ID string `gorm:"primaryKey;size:36"` - TokenHash string `gorm:"size:64"` - Purpose string `gorm:"size:24"` - Method string `gorm:"size:24"` - UserID string `gorm:"size:36"` - Email string `gorm:"size:160"` - Phone string `gorm:"size:24"` - EmailHash string `gorm:"size:64"` - PhoneHash string `gorm:"size:64"` - PolicyHash string `gorm:"size:64"` - Ready bool - Attempts int - ExpiresAt time.Time `gorm:"index"` - UsedAt *time.Time - CreatedAt time.Time + SessionID string `json:"-" gorm:"size:36"` + CredentialHash string `json:"-" gorm:"size:64"` + ID string `gorm:"primaryKey;size:36"` + TokenHash string `gorm:"size:64"` + Purpose string `gorm:"size:24"` + Method string `gorm:"size:24"` + UserID string `gorm:"size:36"` + Email string `gorm:"size:160"` + Phone string `gorm:"size:24"` + EmailHash string `gorm:"size:64"` + PhoneHash string `gorm:"size:64"` + PolicyHash string `gorm:"size:64"` + Ready bool + Attempts int + ExpiresAt time.Time `gorm:"index"` + UsedAt *time.Time + CreatedAt time.Time } // Fixed window quotas and per-recipient cooldowns are shared by all instances. diff --git a/backend/internal/model/models_user_avatar.go b/backend/internal/model/models_user_avatar.go new file mode 100644 index 000000000..2b4b6577c --- /dev/null +++ b/backend/internal/model/models_user_avatar.go @@ -0,0 +1,10 @@ +package model + +import "time" + +// UserAvatar keeps the bounded image payload out of ordinary user queries. +type UserAvatar struct { + UserID string `gorm:"primaryKey;size:36" json:"userId"` + Data []byte `gorm:"not null" json:"-"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/repository/account.go b/backend/internal/repository/account.go new file mode 100644 index 000000000..de6ab90d5 --- /dev/null +++ b/backend/internal/repository/account.go @@ -0,0 +1,174 @@ +package repository + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "yingce/backend/internal/model" +) + +var ErrProfileAuthorization = errors.New("账号或身份验证状态已变化,请重新登录或验证") + +const ProfileReauthenticationTTL = 5 * time.Minute + +func ProfileCredentialHash(passwordHash string) string { + v := sha256.Sum256([]byte(passwordHash)) + return hex.EncodeToString(v[:]) +} + +func lockProfileUser(tx *gorm.DB, userID string) error { + if tx.Dialector.Name() == "sqlite" { + return tx.Model(&model.User{}).Where("id = ?", userID).UpdateColumn("updated_at", gorm.Expr("updated_at")).Error + } + return nil +} + +// Serialize session issuance with credential changes so a login validated +// before revocation cannot insert an authenticated session afterwards. +func (r *Repository) CreateAuthSessionForUser(expected *model.User, session *model.AuthSession, events ...*model.LoginLog) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if session.UserID != expected.ID { + return ErrProfileAuthorization + } + if err := lockProfileUser(tx, expected.ID); err != nil { + return err + } + var current model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, "id = ?", expected.ID).Error; err != nil { + return err + } + if current.Status != model.UserStatusActive || current.PasswordHash != expected.PasswordHash || current.Email != expected.Email { + return ErrProfileAuthorization + } + if err := tx.Create(session).Error; err != nil { + return err + } + for _, event := range events { + if event.UserID == nil || *event.UserID != expected.ID || event.Event != "login" || event.Result != "success" { + return ErrProfileAuthorization + } + if err := tx.Create(event).Error; err != nil { + return err + } + } + return nil + }) +} + +func profileAuthorization(tx *gorm.DB, expected *model.User, session *model.AuthSession, now time.Time, requireReauth bool) error { + var current model.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, "id = ?", expected.ID).Error; err != nil { + return err + } + if current.Status != model.UserStatusActive || current.PasswordHash != expected.PasswordHash || current.Email != expected.Email { + return ErrProfileAuthorization + } + var stored model.AuthSession + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&stored, "id = ? AND user_id = ?", session.ID, expected.ID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrProfileAuthorization + } + return err + } + // A transaction may wait for another account write; expiry is checked after + // taking the locks rather than against the request's earlier timestamp. + now = time.Now() + if stored.TokenHash != session.TokenHash || !stored.ExpiresAt.After(now) { + return ErrProfileAuthorization + } + if requireReauth && (stored.ReauthenticatedAt == nil || stored.ReauthenticatedAt.After(now) || !stored.ReauthenticatedAt.Add(ProfileReauthenticationTTL).After(now)) { + return ErrProfileAuthorization + } + return nil +} + +func (r *Repository) ChangeProfilePassword(expected *model.User, session *model.AuthSession, newHash string, now time.Time) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := lockProfileUser(tx, expected.ID); err != nil { + return err + } + if err := profileAuthorization(tx, expected, session, now, expected.PasswordHash == ""); err != nil { + return err + } + result := tx.Model(&model.User{}).Where("id = ? AND password_hash = ? AND status = ?", expected.ID, expected.PasswordHash, model.UserStatusActive).Updates(map[string]any{"password_hash": newHash, "updated_at": now}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrProfileAuthorization + } + return tx.Where("user_id = ?", expected.ID).Delete(&model.AuthSession{}).Error + }) +} + +func (r *Repository) ChangeProfileEmail(expected *model.User, session *model.AuthSession, v *model.AuthVerification, now time.Time) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := lockRegistrationEmail(tx, v.Email); err != nil { + return err + } + if err := profileAuthorization(tx, expected, session, now, expected.PasswordHash == ""); err != nil { + return err + } + now = time.Now() + if err := New(tx).CheckEmailAvailable(v.Email, expected.ID); err != nil { + return err + } + result := tx.Model(&model.AuthVerification{}).Where("id = ? AND user_id = ? AND session_id = ? AND purpose = ? AND credential_hash = ? AND email = ? AND ready = ? AND used_at IS NULL AND expires_at > ? AND attempts <= 5", v.ID, expected.ID, session.ID, "profile_email", ProfileCredentialHash(expected.PasswordHash), v.Email, true, now).Update("used_at", now) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrVerificationInvalid + } + result = tx.Model(&model.User{}).Where("id = ? AND password_hash = ? AND status = ?", expected.ID, expected.PasswordHash, model.UserStatusActive).Updates(map[string]any{"email": v.Email, "email_verified_at": now, "updated_at": now}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrProfileAuthorization + } + return tx.Where("user_id = ?", expected.ID).Delete(&model.AuthSession{}).Error + }) +} + +func (r *Repository) ReauthenticateLinuxDOSession(expected *model.User, session *model.AuthSession, subject string, now time.Time) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := lockProfileUser(tx, expected.ID); err != nil { + return err + } + if err := profileAuthorization(tx, expected, session, now, false); err != nil { + return err + } + var count int64 + if err := tx.Model(&model.UserIdentity{}).Where("provider = ? AND subject = ? AND user_id = ?", "linuxdo", subject, expected.ID).Count(&count).Error; err != nil { + return err + } + if count != 1 { + return ErrProfileAuthorization + } + result := tx.Model(&model.AuthSession{}).Where("id = ? AND user_id = ? AND token_hash = ?", session.ID, expected.ID, session.TokenHash).Updates(map[string]any{"reauthenticated_at": now, "updated_at": now}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrProfileAuthorization + } + return nil + }) +} + +func (r *Repository) LinuxDOReauthenticationState(stateHash string) (bool, error) { + var state model.OAuthState + err := r.db.First(&state, "provider = ? AND state_hash = ?", "linuxdo", stateHash).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return state.ReauthUserID != "" && state.ReauthSessionID != "", nil +} diff --git a/backend/internal/repository/account_test.go b/backend/internal/repository/account_test.go new file mode 100644 index 000000000..8d84ca867 --- /dev/null +++ b/backend/internal/repository/account_test.go @@ -0,0 +1,243 @@ +package repository + +import ( + "errors" + "path/filepath" + "sync" + "testing" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "yingce/backend/internal/model" +) + +func profileRepositoryDB(t *testing.T) (*gorm.DB, string) { + t.Helper() + dsn := filepath.Join(t.TempDir(), "profile.db") + "?_busy_timeout=5000&_journal_mode=WAL" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.AuthVerification{}, &model.UserIdentity{}); err != nil { + t.Fatal(err) + } + sqlDB, _ := db.DB() + t.Cleanup(func() { _ = sqlDB.Close() }) + return db, dsn +} + +func TestProfilePasswordConcurrentChangeUsesCredentialCompare(t *testing.T) { + db, dsn := profileRepositoryDB(t) + now := time.Now() + u := &model.User{ID: "one", Username: "one", Status: model.UserStatusActive, PasswordHash: "old-hash"} + session := &model.AuthSession{ID: "session", UserID: u.ID, TokenHash: "token-hash", ExpiresAt: now.Add(time.Hour)} + if err := db.Create(u).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(session).Error; err != nil { + t.Fatal(err) + } + start := make(chan struct{}) + results := make(chan error, 2) + var wg sync.WaitGroup + for _, newHash := range []string{"new-one", "new-two"} { + peer, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := peer.DB() + t.Cleanup(func() { _ = sqlDB.Close() }) + wg.Add(1) + go func() { defer wg.Done(); <-start; results <- New(peer).ChangeProfilePassword(u, session, newHash, now) }() + } + close(start) + wg.Wait() + close(results) + success := 0 + for err := range results { + if err == nil { + success++ + } else if !errors.Is(err, ErrProfileAuthorization) { + t.Fatal(err) + } + } + if success != 1 { + t.Fatalf("successful changes=%d", success) + } + var count int64 + db.Model(&model.AuthSession{}).Count(&count) + if count != 0 { + t.Fatal("session remained") + } +} + +func TestLinuxDOReauthenticationPersistsAcrossSessionTimezones(t *testing.T) { + for _, offset := range []int{-12, 14} { + t.Run(time.FixedZone("session", offset*3600).String(), func(t *testing.T) { + db, _ := profileRepositoryDB(t) + now := time.Now().UTC() + u := &model.User{ID: "one", Username: "one", Status: model.UserStatusActive} + session := &model.AuthSession{ID: "session", UserID: u.ID, TokenHash: "token", ExpiresAt: now.Add(time.Hour).In(time.FixedZone("session", offset*3600))} + identity := &model.UserIdentity{ID: "identity", UserID: u.ID, Provider: "linuxdo", Subject: "subject"} + for _, row := range []any{u, session, identity} { + if err := db.Create(row).Error; err != nil { + t.Fatal(err) + } + } + if err := New(db).ReauthenticateLinuxDOSession(u, session, identity.Subject, now); err != nil { + t.Fatal(err) + } + var stored model.AuthSession + if err := db.First(&stored, "id = ?", session.ID).Error; err != nil { + t.Fatal(err) + } + if stored.ReauthenticatedAt == nil || !stored.ReauthenticatedAt.Equal(now) { + t.Fatal("successful reauthentication did not persist its timestamp") + } + }) + } +} + +func TestProfileEmailConcurrentCanonicalCollisionDoesNotConsumeLoser(t *testing.T) { + db, dsn := profileRepositoryDB(t) + now := time.Now() + start := make(chan struct{}) + results := make(chan error, 2) + var wg sync.WaitGroup + for i, id := range []string{"one", "two"} { + u := &model.User{ID: id, Username: id, Status: model.UserStatusActive, PasswordHash: "hash"} + session := &model.AuthSession{ID: id, UserID: id, TokenHash: "token", ExpiresAt: now.Add(time.Hour)} + v := &model.AuthVerification{ID: id, UserID: id, SessionID: id, CredentialHash: ProfileCredentialHash("hash"), Purpose: "profile_email", Email: []string{"a.b+first@gmail.com", "ab+second@googlemail.com"}[i], Ready: true, ExpiresAt: now.Add(time.Hour)} + for _, row := range []any{u, session, v} { + if err := db.Create(row).Error; err != nil { + t.Fatal(err) + } + } + peer, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := peer.DB() + t.Cleanup(func() { _ = sqlDB.Close() }) + wg.Add(1) + go func() { defer wg.Done(); <-start; results <- New(peer).ChangeProfileEmail(u, session, v, now) }() + } + close(start) + wg.Wait() + close(results) + success := 0 + for err := range results { + if err == nil { + success++ + } else if err.Error() != "邮箱已被注册" { + t.Fatal(err) + } + } + if success != 1 { + t.Fatalf("successful changes=%d", success) + } + var count int64 + db.Model(&model.AuthVerification{}).Where("used_at IS NOT NULL").Count(&count) + if count != 1 { + t.Fatalf("consumed=%d", count) + } + db.Model(&model.AuthSession{}).Count(&count) + if count != 1 { + t.Fatalf("remaining sessions=%d", count) + } +} + +func TestProfileEmailRollbackWhenSessionRevocationFails(t *testing.T) { + db, _ := profileRepositoryDB(t) + now := time.Now() + u := &model.User{ID: "one", Username: "one", Email: "old@example.com", PasswordHash: "hash", Status: model.UserStatusActive} + session := &model.AuthSession{ID: "session", UserID: u.ID, TokenHash: "token", ExpiresAt: now.Add(time.Hour)} + v := &model.AuthVerification{ID: "ticket", Purpose: "profile_email", UserID: u.ID, SessionID: session.ID, Email: "new@example.com", CredentialHash: ProfileCredentialHash("hash"), Ready: true, ExpiresAt: now.Add(time.Hour)} + for _, row := range []any{u, session, v} { + if err := db.Create(row).Error; err != nil { + t.Fatal(err) + } + } + if err := db.Exec(`CREATE TRIGGER fail_revoke BEFORE DELETE ON auth_sessions BEGIN SELECT RAISE(ABORT, 'revocation failed'); END`).Error; err != nil { + t.Fatal(err) + } + if err := New(db).ChangeProfileEmail(u, session, v, now); err == nil { + t.Fatal("revocation failure ignored") + } + var stored model.User + if err := db.First(&stored, "id = ?", u.ID).Error; err != nil { + t.Fatal(err) + } + if stored.Email != u.Email || stored.EmailVerifiedAt != nil { + t.Fatal("email update not rolled back") + } + var ticket model.AuthVerification + if err := db.First(&ticket, "id = ?", v.ID).Error; err != nil { + t.Fatal(err) + } + if ticket.UsedAt != nil { + t.Fatal("ticket consumption not rolled back") + } +} + +func TestProfileCredentialChangeRejectsInFlightLoginSession(t *testing.T) { + db, _ := profileRepositoryDB(t) + u := &model.User{ID: "one", Username: "one", Status: model.UserStatusActive, PasswordHash: "old-hash", Email: "old@example.com"} + if err := db.Create(u).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&model.User{}).Where("id = ?", u.ID).Update("password_hash", "new-hash").Error; err != nil { + t.Fatal(err) + } + session := &model.AuthSession{ID: "new-session", UserID: u.ID, TokenHash: "token", ExpiresAt: time.Now().Add(time.Hour)} + if err := New(db).CreateAuthSessionForUser(u, session); !errors.Is(err, ErrProfileAuthorization) { + t.Fatalf("stale login accepted: %v", err) + } + var count int64 + db.Model(&model.AuthSession{}).Count(&count) + if count != 0 { + t.Fatal("stale login inserted session") + } +} + +func TestProfilePasswordRevocationSerializesWithConcurrentSessionIssuance(t *testing.T) { + db, dsn := profileRepositoryDB(t) + now := time.Now() + u := &model.User{ID: "one", Username: "one", Status: model.UserStatusActive, PasswordHash: "old-hash"} + oldSession := &model.AuthSession{ID: "old-session", UserID: u.ID, TokenHash: "old-token", ExpiresAt: now.Add(time.Hour)} + if err := db.Create(u).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(oldSession).Error; err != nil { + t.Fatal(err) + } + peer, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := peer.DB() + t.Cleanup(func() { _ = sqlDB.Close() }) + start := make(chan struct{}) + issued := make(chan error, 1) + changed := make(chan error, 1) + go func() { + <-start + issued <- New(peer).CreateAuthSessionForUser(u, &model.AuthSession{ID: "new-session", UserID: u.ID, TokenHash: "new-token", ExpiresAt: now.Add(time.Hour)}) + }() + go func() { <-start; changed <- New(db).ChangeProfilePassword(u, oldSession, "new-hash", now) }() + close(start) + if err := <-changed; err != nil { + t.Fatal(err) + } + if err := <-issued; err != nil && !errors.Is(err, ErrProfileAuthorization) { + t.Fatal(err) + } + var count int64 + if err := db.Model(&model.AuthSession{}).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("sessions surviving concurrent revocation=%d", count) + } +} diff --git a/backend/internal/repository/login_logs.go b/backend/internal/repository/login_logs.go new file mode 100644 index 000000000..b2828ff17 --- /dev/null +++ b/backend/internal/repository/login_logs.go @@ -0,0 +1,108 @@ +package repository + +import ( + "errors" + "time" + "yingce/backend/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type LoginLogFilter struct { + Keyword string + Event string + Method string + Result string + IP string + From *time.Time + To *time.Time +} + +func (r *Repository) AppendLoginLog(event *model.LoginLog) error { return r.db.Create(event).Error } + +func (r *Repository) LoginLogs(filter LoginLogFilter, limit, offset int) ([]model.LoginLog, int64, error) { + query := r.db.Model(&model.LoginLog{}) + if filter.Keyword != "" { + term := "%" + filter.Keyword + "%" + query = query.Where("lower(username) LIKE lower(?) OR lower(display_name) LIKE lower(?) OR lower(account) LIKE lower(?) OR user_id = ?", term, term, term, filter.Keyword) + } + if filter.Event != "" { + query = query.Where("event = ?", filter.Event) + } + if filter.Method != "" { + query = query.Where("method = ?", filter.Method) + } + if filter.Result != "" { + query = query.Where("result = ?", filter.Result) + } + if filter.IP != "" { + query = query.Where("ip = ?", filter.IP) + } + if filter.From != nil { + query = query.Where("created_at >= ?", filter.From.UTC()) + } + if filter.To != nil { + query = query.Where("created_at <= ?", filter.To.UTC()) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + logs := make([]model.LoginLog, 0) + err := query.Order("created_at DESC, id DESC").Limit(limit).Offset(offset).Find(&logs).Error + return logs, total, err +} + +func (r *Repository) LoginLog(id string) (*model.LoginLog, error) { + var event model.LoginLog + if err := r.db.First(&event, "id = ?", id).Error; err != nil { + return nil, err + } + return &event, nil +} + +// 凭据校验、撤销和日志提交在同一事务中;只有真正撤销的有效会话写成功事件。 +func (r *Repository) RevokeAuthSessionWithLog(sessionID, tokenHash string, event *model.LoginLog) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // SQLite 先取得写锁,避免并发退出在读快照后升级写锁时互相冲突。 + if tx.Dialector.Name() == "sqlite" { + if err := tx.Model(&model.AuthSession{}).Where("id = ? AND token_hash = ?", sessionID, tokenHash).UpdateColumn("updated_at", gorm.Expr("updated_at")).Error; err != nil { + return err + } + } + var session model.AuthSession + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&session, "id = ? AND token_hash = ?", sessionID, tokenHash).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + if err != nil { + return err + } + // 既有 SQLite 会话可能保存不同时区的时间字符串,不能按 SQL 字符串比较有效期。 + if !session.ExpiresAt.After(time.Now()) { + return nil + } + var user model.User + if err := tx.First(&user, "id = ?", session.UserID).Error; err != nil { + return err + } + event.UserID = &user.ID + event.Username, event.DisplayName, event.Account = user.Username, user.DisplayName, user.Username + var login model.LoginLog + err = tx.Where("session_ref = ? AND event = ? AND result = ?", event.SessionRef, "login", "success").First(&login).Error + if err == nil { + event.Method = login.Method + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + deleted := tx.Where("id = ? AND token_hash = ? AND expires_at = ?", session.ID, tokenHash, session.ExpiresAt).Delete(&model.AuthSession{}) + if deleted.Error != nil { + return deleted.Error + } + if deleted.RowsAffected == 0 { + return nil + } + return tx.Create(event).Error + }) +} diff --git a/backend/internal/repository/login_logs_test.go b/backend/internal/repository/login_logs_test.go new file mode 100644 index 000000000..38b1224ff --- /dev/null +++ b/backend/internal/repository/login_logs_test.go @@ -0,0 +1,144 @@ +package repository + +import ( + "fmt" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "path/filepath" + "sync" + "testing" + "time" + "yingce/backend/internal/model" +) + +func TestRevokeAuthSessionConcurrentWritesSingleLogout(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "logout.db")+"?_busy_timeout=5000&_journal_mode=WAL"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := db.DB() + t.Cleanup(func() { _ = sqlDB.Close() }) + if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.LoginLog{}); err != nil { + t.Fatal(err) + } + u := model.User{ID: "concurrent-user", Username: "concurrent-user", DisplayName: "Creator"} + now := time.Now() + if err := db.Create(&u).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.AuthSession{ID: "session", UserID: u.ID, TokenHash: "token-hash", ExpiresAt: now.Add(time.Hour), UpdatedAt: now}).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.LoginLog{ID: "login", UserID: &u.ID, Event: "login", Result: "success", Method: "email", SessionRef: "session-ref", CreatedAt: now}).Error; err != nil { + t.Fatal(err) + } + r := New(db) + start := make(chan struct{}) + errs := make(chan error, 4) + var wg sync.WaitGroup + for i := 0; i < 4; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + errs <- r.RevokeAuthSessionWithLog("session", "token-hash", &model.LoginLog{ID: fmt.Sprintf("logout-%d", i), Event: "logout", Result: "success", Method: "unknown", Source: "logout", SessionRef: "session-ref", CreatedAt: time.Now()}) + }(i) + } + close(start) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal("concurrent logout failed", err) + } + } + var events []model.LoginLog + if err := db.Where("event = ?", "logout").Find(&events).Error; err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0].Method != "email" || events[0].Username != u.Username { + t.Fatalf("concurrent logout logs: %#v", events) + } +} + +func TestLoginLogFiltersKeepHistoricalSnapshots(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := db.DB() + t.Cleanup(func() { _ = sqlDB.Close() }) + if err := db.AutoMigrate(&model.LoginLog{}); err != nil { + t.Fatal(err) + } + at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + id := "historical-user" + for _, event := range []model.LoginLog{ + {ID: "first", UserID: &id, Username: "Creator", DisplayName: "历史昵称", Event: "login", Method: "password", Result: "success", IP: "192.0.2.5", CreatedAt: at}, + {ID: "second", Username: "Another", Event: "login", Method: "email", Result: "failed", IP: "192.0.2.6", CreatedAt: at.Add(time.Hour)}, + } { + if err := db.Create(&event).Error; err != nil { + t.Fatal(err) + } + } + r := New(db) + filter := LoginLogFilter{Keyword: "creator", Event: "login", Method: "password", Result: "success", IP: "192.0.2.5", From: &at, To: &at} + logs, total, err := r.LoginLogs(filter, 20, 0) + if err != nil || total != 1 || len(logs) != 1 || logs[0].DisplayName != "历史昵称" { + t.Fatalf("boundary filter: %#v %d %v", logs, total, err) + } + offsetBound := at.In(time.FixedZone("UTC+8", 8*60*60)) + filter.From, filter.To = &offsetBound, &offsetBound + logs, total, err = r.LoginLogs(filter, 20, 0) + if err != nil || total != 1 || len(logs) != 1 { + t.Fatalf("same instant in another timezone: %#v %d %v", logs, total, err) + } + logs, total, err = r.LoginLogs(LoginLogFilter{}, 1, 0) + if err != nil || total != 2 || len(logs) != 1 || logs[0].ID != "second" { + t.Fatalf("descending page: %#v %d %v", logs, total, err) + } + logs, total, err = r.LoginLogs(LoginLogFilter{Keyword: "missing"}, 20, 0) + if err != nil || total != 0 || logs == nil || len(logs) != 0 { + t.Fatalf("empty array: %#v %d %v", logs, total, err) + } +} + +func TestLogoutExpirationUsesInstantAcrossTimezones(t *testing.T) { + for _, valid := range []bool{true, false} { + t.Run(fmt.Sprintf("valid=%t", valid), func(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, _ := db.DB() + t.Cleanup(func() { _ = sqlDB.Close() }) + if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.LoginLog{}); err != nil { + t.Fatal(err) + } + u := model.User{ID: "timezone-user", Username: "timezone-user"} + if err := db.Create(&u).Error; err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + expires, eventTime := now.Add(-time.Hour).In(time.FixedZone("UTC+8", 8*60*60)), now + if valid { + expires = now.Add(time.Hour) + eventTime = now.In(time.FixedZone("UTC+8", 8*60*60)) + } + if err := db.Create(&model.AuthSession{ID: "timezone-session", UserID: u.ID, TokenHash: "token", ExpiresAt: expires}).Error; err != nil { + t.Fatal(err) + } + event := &model.LoginLog{ID: "timezone-log", Event: "logout", Result: "success", Method: "unknown", CreatedAt: eventTime} + if err := New(db).RevokeAuthSessionWithLog("timezone-session", "token", event); err != nil { + t.Fatal(err) + } + var count int64 + if err := db.Model(&model.LoginLog{}).Count(&count).Error; err != nil { + t.Fatal(err) + } + if (count == 1) != valid { + t.Fatalf("expiration is compared by wall clock rather than instant: logs=%d, valid=%t", count, valid) + } + }) + } +} diff --git a/backend/internal/repository/user_avatar.go b/backend/internal/repository/user_avatar.go new file mode 100644 index 000000000..84382b626 --- /dev/null +++ b/backend/internal/repository/user_avatar.go @@ -0,0 +1,53 @@ +package repository + +import ( + "errors" + "time" + + "yingce/backend/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ErrProfileAvatarUserUnavailable = errors.New("avatar owner is not active") + +func (r *Repository) UserAvatar(userID string) (*model.UserAvatar, error) { + var avatar model.UserAvatar + err := r.db.First(&avatar, "user_id = ?", userID).Error + return &avatar, err +} + +// UpdateUserAvatar serializes avatar writes on the user row so the payload and +// its cache version always belong to the same committed selection. +func (r *Repository) UpdateUserAvatar(userID string, data []byte, version string, preset string, now time.Time) (*model.User, error) { + var user model.User + err := r.db.Transaction(func(tx *gorm.DB) error { + mode := "default" + if len(data) > 0 { + mode = "custom" + } + updates := map[string]any{"avatar_mode": mode, "avatar_version": version, "updated_at": now} + if mode == "default" { + updates["avatar_preset"] = preset + } + result := tx.Model(&model.User{}).Where("id = ? AND status = ?", userID, model.UserStatusActive). + Updates(updates) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrProfileAvatarUserUnavailable + } + if mode == "custom" { + avatar := model.UserAvatar{UserID: userID, Data: data, UpdatedAt: now} + if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "user_id"}}, DoUpdates: clause.AssignmentColumns([]string{"data", "updated_at"})}).Create(&avatar).Error; err != nil { + return err + } + } else if err := tx.Delete(&model.UserAvatar{}, "user_id = ?", userID).Error; err != nil { + return err + } + return tx.First(&user, "id = ?", userID).Error + }) + return &user, err +} diff --git a/backend/internal/service/aliases_login_logs.go b/backend/internal/service/aliases_login_logs.go new file mode 100644 index 000000000..8af11b796 --- /dev/null +++ b/backend/internal/service/aliases_login_logs.go @@ -0,0 +1,7 @@ +package service + +import "yingce/backend/internal/app" + +type LoginContext = app.LoginContext +type LoginLogQuery = app.LoginLogQuery +type LoginLogPage = app.LoginLogPage diff --git a/backend/internal/service/aliases_profile.go b/backend/internal/service/aliases_profile.go new file mode 100644 index 000000000..1db768978 --- /dev/null +++ b/backend/internal/service/aliases_profile.go @@ -0,0 +1,10 @@ +package service + +import "yingce/backend/internal/app" + +type UserProfile = app.UserProfile +type ProfilePasswordRequest = app.ProfilePasswordRequest +type ProfileEmailCodeRequest = app.ProfileEmailCodeRequest +type ProfileEmailChangeRequest = app.ProfileEmailChangeRequest +type ProfileEmailChangeResult = app.ProfileEmailChangeResult +type VerificationTicket = app.VerificationTicket diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 275c07690..3a4d59827 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -5,6 +5,14 @@ description: 影策后端核心持久化表与状态边界 # 后端数据库 +## 用户登录日志 + +Schema 53 新增 `login_logs`,新库初始化、既有库升级和 SQLite → PostgreSQL 迁移共用此表。字段:`id`、可空 `user_id`、用户快照 `username/display_name/account`、`event`(login/logout)、`method`(password/email/sms/linuxdo/verification/unknown)、`source`(login/registration/logout)、`result`(success/failed)、`ip`、`user_agent`、`session_ref`、`reason/message`、`created_at`。用户、事件、方式、结果、IP、关联会话和时间有查询索引。 + +日志时间统一保存为 UTC,筛选按实际时刻匹配,页面按当前设备时区显示。登录成功日志与会话签发、退出成功日志与有效会话撤销各自在同一事务提交;日志写入失败会回滚会话操作并向调用方报错。退出需匹配 Token 哈希和有效期,既有会话有效期在 Go 中按真实时刻判断,避免 SQLite 不同时区字符串误判;无会话、过期、伪造和重复退出不产生成功事件。会话引用是 ID 的单向哈希,不能用于认证。用户删除/改名、会话到期清理不会改写日志快照。 + +失败登录保留逐条事件,识别不了用户时 `user_id=null`;邮件/手机号尝试账号脱敏,内部错误只记固定分类。密码、验证码、OAuth state/code、Cookie、Token 不进入日志。请求校验/频控拒绝也尝试记录失败,记录失败会输出明确的服务端诊断;原请求仍维持失败状态。不把刷新、重新核验个人身份或自然过期当作登录/主动退出,不补造历史,不自动删除日志。 + ## 分片上传预留 Schema 45 新增 `upload_reservations`:`id`、`user_id`、`size`、`day`、`expires_at`。创建会话时在数据库事务内检查并行会话数、现有资源容量、未到期预留和每日上传用量,更新既有 `user_daily_upload_usages` 后插入预留。SQLite 先取得写锁,PostgreSQL 使用账号级事务 advisory lock。 @@ -31,7 +39,13 @@ Schema 45 新增 `upload_reservations`:`id`、`user_id`、`size`、`day`、`ex ## 结构迁移与版本 -当前结构版本为 50。版本 50 新增 `canvas_mutation_receipts`,按 `(user_id, canvas_id, mutation_id)` 唯一记录画布 operations 请求摘要、确定性结果和提交 revision,供响应丢失后的幂等重放使用;receipt 与画布内容、revision 在同一事务提交。版本 49 `banner_sort_order` 为 `banner_announcements` 增加可配置的 `sort_order` 展示顺序;版本 48 `channel_proxy_url` 为 `model_channels` 增加渠道级出站代理地址 `proxy_url`,支持 `socks5://`、`http://`、`https://`;留空时沿用全局代理。代理地址只返回管理员视图,普通用户目录不暴露渠道代理或其中的认证信息。版本 42 `cloud_agent_pi_sessions` 新增 `cloud_agent_pi_sessions` 表,按运行 ID 保存 Pi 原生 JSONL 会话快照、用户 ID、乐观并发 revision 和更新时间;会话正文由 Agent runtime 管理,Go 只将其作为不透明快照读写,不替代有界控制检查点、事件日志或任务记录。版本 41 `resource_thumbnail` 为 `resources` 增加缩略图元数据。版本 40 `builtin_skill_tombstones` 新增内置技能删除记录,管理员删除平台技能后,后续启动同步不会重新创建该技能;版本 39 `skill_library_categories` 新增 `skill_library_categories` 表,并为 `user_skill_states` 增加 `library_category_id`,保存个人技能库分类及每个用户对技能的归类;分类不改变 `skills.tag` 的技能广场共享分类。版本 38 `prefixed_id_sequence_reconcile` 创建(若缺失)并校准 `id_sequences`:`CHANNEL`、`MODEL`、`PTIER`、`ATTEMPT`、`LMODEL`、`REVISION`、`ROUTE` 按对应业务表的最大有效 ID 前移,包含软删除记录,不回退已有序列、不重写业务 ID。分配新 ID 时再次校准并检查冲突;SQLite → PostgreSQL 迁移完成后也校准序列。 `cloud_agent_gemini_cache` 创建 `cloud_agent_gemini_caches`,保存用户范围内的官方 Gemini 缓存元数据;版本 37 `cloud_agent_gemini_cache_identity` 将缓存标识约束修正为 `(user_id, cache_key)`,移除旧的全局 cache_key 唯一索引。版本 35 `auth_notifications` 为认证通知与组合验证补齐 `auth_verifications`、`notification_quotas`、`sms_channels`、`sms_records` 四张表,并为 `users` 增加 `phone`、`email_verified_at`、`phone_verified_at`,为 `email_verification_codes` 增加 `attempts`;同时创建非空手机号唯一索引。版本 34 `task_media_recovery` 为 `tasks` 增加 `media_recovery_json`(text)和 `media_stage`(最长 24 字)。版本 33 `oauth_state_accepted_terms` 为 `o_auth_states` 增加非空布尔列 `accepted_terms`,默认 `false`,不替历史流程补造同意记录。 +版本 52 `user_avatar_presets` 为 `users` 新增 `avatar_preset`,保存系统头像的稳定标识:`person`、`director`、`camera`、`cat`、`rabbit`、`robot`、`rocket`、`sparkles`。系统头像由前端共享组件渲染;选择时原子更新 default 模式、预设标识并清除上传图片。上传个人头像保留上次系统头像偏好,后续可以再次选择;未知标识被拒绝,不保存客户端提供的任意图片地址。 + +版本 51 `personal_profile_security` 增加个人信息与身份重新核验结构:`users.avatar_mode`(空字符串沿用第三方头像、`custom` 为个人上传、`default` 为系统头像)、`users.avatar_version`(仅用于头像地址,不作为用户 JSON 字段返回);新增 `user_avatars`(主键 `user_id`、标准化 PNG `data`、`updated_at`)。头像图片与用户选择在同一事务保存或清除,独立于创作素材资源;数据库备份同时包含头像。上传最大 5 MiB、1600 万像素,服务端居中裁剪并缩小到不超过 512×512。 + +`auth_sessions.reauthenticated_at` 记录当前会话的 Linux.do 重新核验时间,有效 5 分钟;`o_auth_states.reauth_user_id/reauth_session_id` 绑定发起重新核验的账号及会话。重新核验只允许原绑定的第三方身份,不创建账号或新登录会话。`auth_verifications.session_id/credential_hash` 将 `profile_email` 用途的验证码绑定到当前用户、当前会话和密码状态。密码、邮箱修改及旧会话撤销在同一事务提交;登录会话签发同时检查账号凭据,阻止并发旧密码登录在撤销之后再创建会话。升级执行 `migrate-schema up` / `verify`,禁止手工修改既有迁移记录。 + +当前结构版本为 53;版本 53 新增独立登录日志表 `login_logs`,详见本页“用户登录日志”。版本 50 新增 `canvas_mutation_receipts`,按 `(user_id, canvas_id, mutation_id)` 唯一记录画布 operations 请求摘要、确定性结果和提交 revision,供响应丢失后的幂等重放使用;receipt 与画布内容、revision 在同一事务提交。版本 49 `banner_sort_order` 为 `banner_announcements` 增加可配置的 `sort_order` 展示顺序;版本 48 `channel_proxy_url` 为 `model_channels` 增加渠道级出站代理地址 `proxy_url`,支持 `socks5://`、`http://`、`https://`;留空时沿用全局代理。代理地址只返回管理员视图,普通用户目录不暴露渠道代理或其中的认证信息。版本 42 `cloud_agent_pi_sessions` 新增 `cloud_agent_pi_sessions` 表,按运行 ID 保存 Pi 原生 JSONL 会话快照、用户 ID、乐观并发 revision 和更新时间;会话正文由 Agent runtime 管理,Go 只将其作为不透明快照读写,不替代有界控制检查点、事件日志或任务记录。版本 41 `resource_thumbnail` 为 `resources` 增加缩略图元数据。版本 40 `builtin_skill_tombstones` 新增内置技能删除记录,管理员删除平台技能后,后续启动同步不会重新创建该技能;版本 39 `skill_library_categories` 新增 `skill_library_categories` 表,并为 `user_skill_states` 增加 `library_category_id`,保存个人技能库分类及每个用户对技能的归类;分类不改变 `skills.tag` 的技能广场共享分类。版本 38 `prefixed_id_sequence_reconcile` 创建(若缺失)并校准 `id_sequences`:`CHANNEL`、`MODEL`、`PTIER`、`ATTEMPT`、`LMODEL`、`REVISION`、`ROUTE` 按对应业务表的最大有效 ID 前移,包含软删除记录,不回退已有序列、不重写业务 ID。分配新 ID 时再次校准并检查冲突;SQLite → PostgreSQL 迁移完成后也校准序列。 `cloud_agent_gemini_cache` 创建 `cloud_agent_gemini_caches`,保存用户范围内的官方 Gemini 缓存元数据;版本 37 `cloud_agent_gemini_cache_identity` 将缓存标识约束修正为 `(user_id, cache_key)`,移除旧的全局 cache_key 唯一索引。版本 35 `auth_notifications` 为认证通知与组合验证补齐 `auth_verifications`、`notification_quotas`、`sms_channels`、`sms_records` 四张表,并为 `users` 增加 `phone`、`email_verified_at`、`phone_verified_at`,为 `email_verification_codes` 增加 `attempts`;同时创建非空手机号唯一索引。版本 34 `task_media_recovery` 为 `tasks` 增加 `media_recovery_json`(text)和 `media_stage`(最长 24 字)。版本 33 `oauth_state_accepted_terms` 为 `o_auth_states` 增加非空布尔列 `accepted_terms`,默认 `false`,不替历史流程补造同意记录。 升级前备份,按既有流程执行 `migrate-schema up` 和 `verify` 后再启动新后端。旧版本二进制不支持版本 42,回退需恢复匹配的数据库备份,并评估备份之后的新写入;不能只切回旧镜像。关闭自动迁移的生产环境必须运行正式迁移;手工改表不能更新 `schema_migrations` 的版本与校验和记录。 diff --git a/docs/content/docs/backend/code-map.mdx b/docs/content/docs/backend/code-map.mdx index e9c544c45..e93b2df6b 100644 --- a/docs/content/docs/backend/code-map.mdx +++ b/docs/content/docs/backend/code-map.mdx @@ -5,6 +5,14 @@ description: 从产品功能快速定位主要代码入口 # 代码功能地图 +## 用户登录日志 + +- 持久化:`backend/internal/model/models_login_log.go`、`repository/login_logs.go`;会话与日志原子提交在 `repository/account.go`,schema v53 在 `database/migrations.go`。 +- 认证采集:`backend/internal/auth/login_logs.go`、`auth.go`、`verification.go`、`linuxdo.go`;HTTP 上下文和提前拒绝采集在 `handler/admin_login_log_routes.go`。 +- 管理员查询:`app/admin_login_logs.go` → `service/aliases_login_logs.go` → `handler/admin_login_log_routes.go`。 +- 前端:`web/src/services/api/login-logs.ts`、`pages/admin/login-logs/`;菜单和懒加载接入 `admin-shell.tsx`、`router.tsx`。 +- 回归:auth/handler/database 的 `login_logs*_test.go`,前端 `web/test/login-log-device.test.ts`。 + ## 可美 KM Seedream - 声明式请求、响应与参数合同:`plugin-packages/km-kemei-seedream/manifest.json`、`docs/interface.md`;分发包:`plugin-packages/km-kemei-seedream.yingce-plugin`。 @@ -29,6 +37,10 @@ description: 从产品功能快速定位主要代码入口 ## 前端页面 +头像裁剪复用 `components/image-crop-dialog.tsx`,原画布裁剪入口保留同一组件合同;`services/profile-avatar.ts` 校验 1:1 区域、导出有界 PNG,`lib/default-user-avatars.ts` 定义系统头像目录,`UserAvatar` 统一渲染系统预设或个人图片。schema v52 增加系统头像选择。 + +个人信息:`web/src/pages/settings/profile-pane.tsx` 协调头像、密码、邮箱和第三方重新核验,`profile-validation.ts` 提供密码、图片与过期校验,`services/api/profile.ts` 使用统一 `http` 调用。共享头像在 `components/layout/user-avatar.tsx`。后端 `handler/auth_profile.go` 接收鉴权和受限请求,`app/auth_profile.go` / `profile_avatar.go` 组合 auth 域,`auth/account.go` / `avatar.go` / `linuxdo.go` 校验身份与头像内容,`repository/account.go` / `user_avatar.go` 处理会话撤销、邮箱唯一性、验证码消费和头像的事务保存,schema v51 安装个人信息及重新核验字段。 + 云端 Agent:`web/src/components/canvas/canvas-cloud-agent-panel.tsx` 协调对话、Skills 和审批,`canvas-cloud-agent-settings.tsx` 编辑长期偏好与个人记忆,`services/api/agent.ts` 适配运行快照与事件流;画布手工编辑与引用同步共用 `web/src/lib/canvas/canvas-node-prompt.ts`,已有媒体结果只更新 `composerContent` 草稿,不覆盖生成结果和已提交提示词。后端 `handler/agent.go` 负责 HTTP 合同,`app/cloud_agent.go` 负责权限、幂等、可信历史,`app/cloud_agent_profile.go` / `repository/agent_profile.go` 管理用户、项目、画布偏好及 revision CAS,`app/cloud_agent_lessons.go` / `app/cloud_agent_memory_compact.go` / `repository/agent_lesson.go` / `handler/agent_lesson.go` 管理用户私有记忆的写入脱敏、召回注入、用户自审批与压缩优化,`web/src/pages/settings/agent-memory-pane.tsx` 是用户批准/添加/导入导出/压缩入口,`web/src/pages/admin/components/agent-lessons-panel.tsx` 是管理员按用户查看入口,`internal/prompts/agent-*-policy.md` 是随发布物编译、并在文档头声明 ID/版本的行为资源,`app/cloud_agent_policy.go` 编译本轮策略快照,`internal/canvas/capability/` 注册服务端节点能力和字段映射,`app/cloud_agent_canvas_state.go` 投影画布摘要和精读,`app/cloud_agent_runtime.go` / `app/cloud_agent_pi_coordinator.go` 负责可恢复编排,原生 Pi runtime 协议适配位于 `internal/agent/runtime/` 与 `agent-runtime/pi/agent-runtime.mjs`;`cloud_agent_pi_sessions` 独立保存原生 JSONL 会话,控制检查点仍由 Go 管理。工具根据能力定义生成 schema,并从固定技能快照渐进读取 `SKILL.md` / reference;`app/cloud_agent_media.go` 负责收费媒体任务适配,`app/task_creation.go` 执行预算 admission,运行沿用任务 Worker。节点新增需要能力描述符和真实画布实现;新生成模式还需任务适配器,不由偏好或技能 md 授权。协议见 HTTP API 专题,未确认的真实登录态与上游联调见待测试清单。 | 功能 | 页面入口 | 主要协作模块 | diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 1687ffddb..2d14dd1e0 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -5,6 +5,37 @@ description: 信封、错误码与 OpenAPI # HTTP API 合同 +## 用户登录日志 + +菜单“用户与运营 → 用户登录日志”,页面 `/admin/login-logs`。查询在 service 校验管理员权限:匿名 401、普通用户 403。 + +- `GET /api/admin/login-logs`:支持 `keyword`(用户名、昵称、脱敏账号或用户 ID)、`event=login|logout`、`method=password|email|sms|linuxdo|verification|unknown`、`result=success|failed`、精确 `ip`、RFC3339 `from/to`(含边界)、`page/pageSize`。开始时间不能晚于结束时间。默认每页 20 条、时间倒序;成功 `data={logs,total,page,pageSize}`,空结果 `logs=[]`。 +- `GET /api/admin/login-logs/:id`:成功 `data={log}`;不存在返回 404。包含用户快照、时间、事件、来源、方式、IP、UA、安全原因码/文案和非凭据的关联会话标识。 + +后端在密码、邮箱/短信验证码、Linux.do 登录、注册后自动登录及主动退出的真实认证路径记录,不提供客户端任意新增日志接口。`verification` 表示凭据/请求无效导致方式无法识别;`unknown` 表示上线前会话未保存方式。`source=registration` 标记注册自动登录。退出失败不清 Cookie,不再忽略撤销错误。刷新状态和会话自然过期不新增事件。浏览器/系统由页面根据 UA 展示,无法识别时明确标注;UA 是客户端自报信息。 + +## 个人信息 + +所有 `/auth/profile` 路径均要求当前活动用户的登录 Cookie,不能通过用户 ID 修改他人资料。 + +| 路径 | 入参与结果 | +| --- | --- | +| `GET /auth/profile` | 返回 `user`、`hasPassword`、`emailEnabled`、`linuxdoEnabled` 和可选 `reauthenticatedUntil`;密码散列、头像图片正文、内部缓存版本均不在用户 JSON 中 | +| `PUT /auth/profile/avatar` | multipart `file`,只接受真实 PNG/JPEG、最大 5 MiB;返回 `{ user }`,包含带版本的头像地址 | +| `DELETE /auth/profile/avatar` | 清除个人头像并明确选择系统默认头像;第三方重新登录不会覆盖该选择;返回 `{ user }` | +| `POST /auth/profile/avatar/default` | `{ preset }`,只接受 `person/director/camera/cat/rabbit/robot/rocket/sparkles`;保存选择并清除个人图片,返回 `{ user }` | +| `GET /auth/profile/avatar/:userId` | 只读取本账号的 PNG,`Cache-Control: private, no-store`;账号之间拒绝读取 | +| `POST /auth/profile/password` | `{ currentPassword, newPassword }`;新密码至少 8 个 Unicode 字符、UTF-8 不超过 72 字节;返回 `{ changed: true }` 并清除 Cookie、撤销全部会话 | +| `POST /auth/profile/email-code` | `{ email, currentPassword }`;向未占用的新邮箱发送验证码,返回 `{ ticket, expiresIn, retryAfter }` | +| `POST /auth/profile/email` | `{ ticket, emailCode, currentPassword }`;验证后更新邮箱及验证时间、撤销全部会话并清除 Cookie;返回 `{ changed: true, notificationStatus }` | +| `GET /auth/profile/linuxdo/start` | 纯第三方账号重新核验身份的入口;绑定原用户和原会话,成功回到 `/settings?section=profile&reauth=success`,失败回到该页的 `reauth=error` 状态 | + +已有本地密码的账号必须验证当前密码。纯 Linux.do 账号设置密码或更换邮箱时,必须先完成同一会话内的重新授权,核验必须匹配原第三方用户标识;有效期为 5 分钟。邮箱验证码用途固定为 `profile_email`,绑定当前会话与凭据状态,6 位数字、10 分钟有效、最多 5 次核验;收件地址跨场景共享 60 秒发送冷却、每小时 5 次和每天 20 次额度。发送失败明确报错;邮件服务未启用时禁止更换邮箱。 + +新邮箱验证之前保留原绑定。更新及验证码消费、登录会话撤销一起提交,唯一性检查在事务内重复执行。更新后向原邮箱发通知,`notificationStatus` 为 `sent`、`failed` 或 `not_needed`;`failed` 表示邮箱已更新、原邮箱通知失败,不能把已成功更新显示成未保存。接口使用现有业务错误信封和限流规则,密码、验证码和 ticket 不进入 URL。 + +上传头像先通过共享裁剪弹窗选择 1:1 区域,支持拖动、缩放裁剪框、键盘调整与重置;确认后导出最大 512×512 的 PNG 到待保存预览,点击“保存头像”才上传。取消裁剪保留原头像;待保存图片可重新调整裁剪。选择系统头像使用现有 AppModal、Avatar 与主题 token,支持 8 个预设,确认后才写入服务端。 + ## Agent 画布权限与大事件 自由画布和项目关联画布均使用 `canvas_projects`。Agent 运行时按登录账号和画布 ID 查询归属,不能把所属项目 ID 当作画布 ID;画布不存在、属于其他账号或数据库读取失败时,不声明读写及导出权限。 diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index 572d59bfd..68bf3edb4 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -5,6 +5,16 @@ description: 当前项目已实现的核心工作台能力与使用入口 # 功能清单 +## 用户登录日志 + +管理员在“用户与运营 → 用户登录日志”查看密码、邮箱/短信验证码、Linux.do 的成功/失败登录、注册自动登录和主动退出。支持用户、事件、方式、结果、IP、时间范围筛选、分页、刷新、详情和失败重试;显示账号快照、设备信息和安全原因。成功记录与会话操作原子提交,重复/伪造/过期退出不产生成功事件;刷新和自然过期不计为登录/主动退出。普通用户不可访问,不记录密码或会话凭据,不补造历史。 + +## 个人信息 + +“更换头像”弹窗集中提供上传图片和系统头像两种方式。头像上传支持固定 1:1 裁剪:拖动裁剪框、调整大小、键盘移动和重置,确认裁剪后返回弹窗预览,在弹窗内保存。系统头像提供默认人像、导演、摄影师、小猫、小兔、机器人、探索者和灵感 8 款,在同一弹窗内选择并保存;保存后全局同步,刷新保持。 + +“账户与设置”新增“个人信息”,展示账号名称与绑定邮箱状态。用户可预览并保存个人头像或恢复系统默认头像,顶栏、侧栏、账户卡片与创作消息同步更新。密码修改与邮箱更换分别保存;修改密码需要当前密码,邮箱更换还需要新邮箱验证码,成功后退出所有登录会话。纯 Linux.do 账号可重新授权核验身份后设置本地密码或更换邮箱。沿用现有设置页、表单、按钮反馈与主题,未增加昵称、用户名或角色编辑。 + ## 可美 KM Seedream 图片 `km-kemei-seedream` 声明式插件接入 Seedream 5.0 Pro / Flash 的文生图与参考图编辑,支持 URL / Base64 响应并由宿主统一保存图片。精确模型 ID 与扩展参数见插件包内 `docs/interface.md`。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 6e38a1e18..a4d894ada 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,29 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 用户登录日志 + +- 已实现:管理员菜单 `/admin/login-logs`、筛选分页详情、后端认证事件记录、schema v53、失败脱敏和会话/日志原子提交。 +- 已验证:Go 1.25.0 `go test ./...` 全量通过;认证、权限、脱敏、事务回滚、并发退出、UTC/北京时间边界和旧库迁移专项通过。Bun 1.3.9 前端 build、lint 通过,设备识别和后台密度相关专项 6 项通过。OpenAPI 新路径和模型解析通过。 +- 代码审查已修正渠道模型导入按钮的过期断言,以及启动和助手浮窗测试在中文目录中的路径解码;Bun 1.3.9 完整前端测试 2245 项通过,构建与 lint 通过。HTTP/认证测试使用隔离数据库和模拟 OAuth,不代表外部服务联调已完成。 +- 提交前完整检查:Go 1.25.0 全量测试通过,独立 PostgreSQL 与 Redis 的 5 项依赖测试全部执行并通过;Node 22.19.0 Agent 运行时参与测试。macOS 跳过 1 项仅适用 Linux errno 的支付程序启动分类测试。Chrome 预演 E2E 65 项断言通过,Go 格式检查通过。 +- 已按用户要求重启本地后端,保留原运行配置与开发数据目录;健康接口 200,新日志接口对匿名请求返回 401,schema 53 和 `login_logs` 表已确认。以上仅确认启动、迁移和鉴权入口,真实登录态页面与外部服务联调仍待验收。 +- 筛选布局已优化并通过真实登录态页面验证:1280px 视口两行、1600px 单行、390px 无页面横向溢出;IP 筛选与重置恢复正常。筛选区使用页面私有容器查询,不影响其他后台页面。 +- [ ] 真实管理员页面:明暗主题、窄屏横向滚动、时间筛选、分页/刷新、详情和读取失败重试;浏览器返回后 URL 筛选保留。 +- [ ] 真实 SMTP/短信/Linux.do:各方式成功及失败登录、注册自动登录、主动退出事件和设备/IP 展示。 +- [ ] PostgreSQL:旧库升级、并发登录/退出和 SQLite → PostgreSQL 数据迁移;部署反向代理须核对可信代理配置及日志 IP。 + +## 个人信息 + +- 已实现:“更换头像”弹窗内切换上传图片和 8 款系统头像;图片经 1:1 裁剪后回到弹窗预览,上传或系统头像都在弹窗内保存,确认后全局同步。 +- 已验证:裁剪几何(含极宽和极高图片)、像素边界、PNG 导出失败、系统头像标识校验和事务回滚、v51→v52 数据保持的专项测试;真实个人信息路由的弹窗切换、图片裁剪预览、取消不写入。 + +- 已实现:账户与设置的个人信息、PNG/JPEG 头像预览和系统默认、密码修改/设置、邮箱更换及 Linux.do 重新核验;沿用现有页面样式、表单交互与反馈。继续创作入口仍定位渠道或模型配置。 +- 已验证:认证、头像、会话撤销/并发签发、验证码归属与过期、迁移的专项测试;前端验证规则与共享头像测试、TypeScript 和 Vite 构建。运行验证记录以交付结果为准。 +- 代码审查已修正资料刷新未同步全局头像,以及 Linux.do 重新验证在会话时区变化时未保存验证状态的问题;覆盖取消请求、账户切换、错误响应归属、头像保存后迟到响应,以及 UTC−12/UTC+14 会话的回归测试通过。 +- [ ] 用户验收:真实页面明暗主题和窄屏,头像全局刷新与失败保留、原密码错误、新邮箱发送及通知失败状态。 +- [ ] 外部联调:真实 SMTP 发送新邮箱验证码/旧邮箱通知,真实 Linux.do 授权回调与不同账号拒绝,实际 PostgreSQL 并发事务。本地测试使用隔离数据与模拟上游,不等同于这些外部联调已完成。 + ## 系统模型「拉取模型」弹窗搜索 - 已实现:后台「模型与资源 → 系统模型」点「拉取模型」后的选择弹窗新增搜索框(按模型标识做大小写不敏感的子串匹配,自动聚焦、可一键清空),因为部分上游端点会返回几百个模型。 diff --git a/docs/design/admin-ui-change-log.md b/docs/design/admin-ui-change-log.md index 4f57e40e7..3379ff5da 100644 --- a/docs/design/admin-ui-change-log.md +++ b/docs/design/admin-ui-change-log.md @@ -2,6 +2,22 @@ 本文档是管理后台 UI 的追踪与回滚台账。后续每批 UI 调整都必须在实施时追加,不覆盖历史记录。 +## 用户登录日志筛选布局收敛 + +- 问题:用户搜索与嵌套筛选容器分别换行,IP 输入被基础样式撑满一行,实际 1280px 视口占四排、工具栏高 168px。 +- 调整:所有控件改为页面私有响应式网格;筛选可用宽度至少 1080px 时单行,640px 以上安排为两行,窄屏双列分组;IP/日期宽度受网格约束,重置入口固定位置。保留原有 URL 参数、筛选请求和分页行为;下拉使用共享组件的 ariaLabel。 +- 文件:`web/src/pages/admin/login-logs/login-logs-page.tsx`、`login-logs-page.css`。不修改其他页面的共享工具栏或全局控件规则。 +- 修改前副本:`.local/ui-change-backups/login-log-filters/login-logs-page.tsx`;SHA-256 `9a7da4db4565763bfd787df678a33acbb27a95809325475b3da78f049f9a771d`。CSS 为新增文件。 +- 实页验证:1280×720 两行、工具栏 80px;1600×900 单行、工具栏 36px;390×844 不产生页面横向溢出。已检查控件无重叠、IP 筛选真实空结果与重置恢复。 + +## 用户登录日志菜单与查询页 + +- 目的:为管理员提供独立的登录、失败和主动退出事件查询。 +- 方向:复用现有请求明细的页面壳、筛选表格、分页和状态 token;详情使用 AppDrawer,无新全局覆盖或独立 HTML。 +- 文件:`web/src/pages/admin/login-logs/`、`web/src/services/api/login-logs.ts`;菜单 `admin-shell.tsx`、懒加载 `router.tsx`。 +- 原始基线:这两个既有文件在任务开始时无本地修改;通过 HEAD 保存到 `.local/ui-change-backups/login-logs/`。SHA-256:`admin-shell.tsx` 为 `3d287fdbac7642c318509a6724f9490ef646a06cbfb0feea50d7db11b51f27c1`;`router.tsx` 为 `66288193a703257e9e56c739fa044dc09e8b896168cbecfb0556f195db8dcf0a`。新增页面/API 无旧版本文件。 +- 验证:运行结果见本次交付;真实登录态明暗/窄屏验收列入 pending-test,不以 build 代替。 + ## 记录和回滚约定 - 项目实际已存在 Git 工作树(工作树根目录为项目根),本次不初始化、不提交、不改写任何 Git 历史。 diff --git a/web/src/components/canvas/canvas-node-crop-dialog.tsx b/web/src/components/canvas/canvas-node-crop-dialog.tsx index 0678fcdb5..0fb8bee11 100644 --- a/web/src/components/canvas/canvas-node-crop-dialog.tsx +++ b/web/src/components/canvas/canvas-node-crop-dialog.tsx @@ -1,164 +1,2 @@ -import { useRef, useState, type PointerEvent } from "react"; -import { Button } from "antd"; -import { AppModal } from "@/components/ui/product/app-modal"; -import { resolveCropPixels, type ImageCropRect } from "@/lib/canvas/image-operation-plan"; -import { resizeSelection, selectAspect, translateSelection, type CropGrip } from "@/lib/canvas/image-crop-selection"; -import { useImageToolSource } from "./use-image-tool-source"; - -export type CanvasImageCropRect = ImageCropRect; -type CropProps = { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: CanvasImageCropRect) => void }; -const initialSelection: ImageCropRect = { x: 0.1, y: 0.1, width: 0.8, height: 0.8 }; -const grips: { name: string; vector: CropGrip }[] = [ - { name: "nw", vector: [-1, -1] }, - { name: "n", vector: [0, -1] }, - { name: "ne", vector: [1, -1] }, - { name: "w", vector: [-1, 0] }, - { name: "e", vector: [1, 0] }, - { name: "sw", vector: [-1, 1] }, - { name: "s", vector: [0, 1] }, - { name: "se", vector: [1, 1] }, -]; -type Gesture = { pointer: number; origin: [number, number]; display: [number, number]; selection: ImageCropRect; grip: CropGrip | null }; - -export function CanvasNodeCropDialog(props: CropProps) { - return props.open && props.dataUrl ? : null; -} - -function CropEditor({ dataUrl, onClose, onConfirm }: CropProps) { - const { size, error } = useImageToolSource(dataUrl); - const [selection, setSelection] = useState(initialSelection); - const [locked, setLocked] = useState(false); - const [preset, setPreset] = useState(""); - const stage = useRef(null); - const gesture = useRef(null); - const output = size ? resolveCropPixels(size, selection) : null; - const begin = (event: PointerEvent, grip: CropGrip | null) => { - if (event.button !== 0 || gesture.current || !stage.current || !size) return; - const bounds = stage.current.getBoundingClientRect(); - if (!bounds.width || !bounds.height) return; - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.setPointerCapture(event.pointerId); - gesture.current = { pointer: event.pointerId, origin: [event.clientX, event.clientY], display: [bounds.width, bounds.height], selection, grip }; - }; - const finish = (event: PointerEvent) => { - if (event.pointerId !== gesture.current?.pointer) return; - gesture.current = null; - if (event.target instanceof HTMLElement && event.target.hasPointerCapture(event.pointerId)) event.target.releasePointerCapture(event.pointerId); - }; - const choose = (value: string) => { - if (!size) return; - const ratio = - value === "原图" - ? 1 - : (() => { - const [w, h] = value.split(":").map(Number); - return (w * size.height) / (h * size.width); - })(); - setSelection((box) => selectAspect(box, ratio)); - setPreset(value); - setLocked(true); - }; - return ( - size && onConfirm(selection)} okText="确认裁剪" okButtonProps={{ disabled: !size }}> -
-
-
{ - const drag = gesture.current; - if (!drag || drag.pointer !== event.pointerId) return; - const dx = (event.clientX - drag.origin[0]) / drag.display[0]; - const dy = (event.clientY - drag.origin[1]) / drag.display[1]; - setSelection(drag.grip ? resizeSelection(drag.selection, drag.grip, dx, dy, locked) : translateSelection(drag.selection, dx, dy)); - }} - onPointerUp={finish} - onPointerCancel={finish} - onLostPointerCapture={finish} - > - 待裁剪图片 - {size ? ( -
begin(event, null)} - onKeyDown={(event) => { - const vectors: Record = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; - const direction = vectors[event.key]; - if (!direction) return; - event.preventDefault(); - event.stopPropagation(); - const step = event.shiftKey ? 10 : 1; - setSelection((box) => translateSelection(box, (direction[0] * step) / size.width, (direction[1] * step) / size.height)); - }} - > - - {grips.map(({ name, vector }) => ( -
- ) : null} -
-
-
- {["原图", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"].map((value) => ( - - ))} -
-
- {output ? `输出 ${output.width} × ${output.height} px` : error || "正在读取图片…"} -
- - -
-
- {error ? ( -

- {error} -

- ) : null} -
-
- ); -} +export { ImageCropDialog as CanvasNodeCropDialog } from "@/components/image-crop-dialog"; +export type { ImageCropRect as CanvasImageCropRect } from "@/components/image-crop-dialog"; diff --git a/web/src/components/canvas/use-image-tool-source.ts b/web/src/components/canvas/use-image-tool-source.ts index 5013d2b99..f895b5400 100644 --- a/web/src/components/canvas/use-image-tool-source.ts +++ b/web/src/components/canvas/use-image-tool-source.ts @@ -1,21 +1 @@ -import { useEffect, useState } from "react"; -import { loadRasterImage } from "@/lib/image-raster"; -import type { PixelSize } from "@/lib/canvas/image-operation-plan"; - -export function useImageToolSource(source: string) { - const [result, setResult] = useState<{ source: string; size: PixelSize | null; error: string }>({ source, size: null, error: "" }); - useEffect(() => { - const request = new AbortController(); - setResult({ source, size: null, error: "" }); - void loadRasterImage(source, request.signal).then( - (image) => { - if (!request.signal.aborted) setResult({ source, size: { width: image.naturalWidth, height: image.naturalHeight }, error: "" }); - }, - (error: unknown) => { - if (!request.signal.aborted) setResult({ source, size: null, error: error instanceof Error ? error.message : "图片读取失败" }); - }, - ); - return () => request.abort(); - }, [source]); - return result.source === source ? result : { source, size: null, error: "" }; -} +export { useImageToolSource } from "@/hooks/use-image-tool-source"; diff --git a/web/src/components/image-crop-dialog.tsx b/web/src/components/image-crop-dialog.tsx new file mode 100644 index 000000000..55f66352c --- /dev/null +++ b/web/src/components/image-crop-dialog.tsx @@ -0,0 +1,190 @@ +import { useRef, useState, type PointerEvent } from "react"; +import { Button } from "antd"; +import { AppModal } from "@/components/ui/product/app-modal"; +import { resolveCropPixels, type ImageCropRect } from "@/lib/canvas/image-operation-plan"; +import { centeredAspectSelection, resizeSelection, selectAspect, translateSelection, type CropGrip } from "@/lib/canvas/image-crop-selection"; +import { useImageToolSource } from "@/hooks/use-image-tool-source"; + +export type { ImageCropRect } from "@/lib/canvas/image-operation-plan"; +export type ImageCropDialogProps = { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: ImageCropRect) => void; fixedAspect?: number; title?: string; confirmLoading?: boolean }; +const initialSelection: ImageCropRect = { x: 0.1, y: 0.1, width: 0.8, height: 0.8 }; +const grips: { name: string; vector: CropGrip }[] = [ + { name: "nw", vector: [-1, -1] }, + { name: "n", vector: [0, -1] }, + { name: "ne", vector: [1, -1] }, + { name: "w", vector: [-1, 0] }, + { name: "e", vector: [1, 0] }, + { name: "sw", vector: [-1, 1] }, + { name: "s", vector: [0, 1] }, + { name: "se", vector: [1, 1] }, +]; +type Gesture = { pointer: number; origin: [number, number]; display: [number, number]; selection: ImageCropRect; grip: CropGrip | null }; + +export function ImageCropDialog(props: ImageCropDialogProps) { + return props.open && props.dataUrl ? : null; +} + +function CropEditor({ dataUrl, onClose, onConfirm, fixedAspect, title = "裁剪图片", confirmLoading = false }: ImageCropDialogProps) { + const { size, error } = useImageToolSource(dataUrl); + const [selected, setSelection] = useState(null); + const selection = selected ?? (size && fixedAspect !== undefined ? centeredAspectSelection(size, fixedAspect) : initialSelection); + const [locked, setLocked] = useState(false); + const aspectLocked = fixedAspect !== undefined || locked; + const [preset, setPreset] = useState(""); + const stage = useRef(null); + const gesture = useRef(null); + const output = size ? resolveCropPixels(size, selection) : null; + const outputLabel = output && fixedAspect === 1 ? `输出 ${Math.min(output.width, output.height)} × ${Math.min(output.width, output.height)} px` : output ? `输出 ${output.width} × ${output.height} px` : error || "正在读取图片…"; + const begin = (event: PointerEvent, grip: CropGrip | null) => { + if (confirmLoading || event.button !== 0 || gesture.current || !stage.current || !size) return; + const bounds = stage.current.getBoundingClientRect(); + if (!bounds.width || !bounds.height) return; + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + gesture.current = { pointer: event.pointerId, origin: [event.clientX, event.clientY], display: [bounds.width, bounds.height], selection, grip }; + }; + const finish = (event: PointerEvent) => { + if (event.pointerId !== gesture.current?.pointer) return; + gesture.current = null; + if (event.target instanceof HTMLElement && event.target.hasPointerCapture(event.pointerId)) event.target.releasePointerCapture(event.pointerId); + }; + const choose = (value: string) => { + if (!size) return; + const ratio = + value === "原图" + ? 1 + : (() => { + const [w, h] = value.split(":").map(Number); + return (w * size.height) / (h * size.width); + })(); + setSelection((box) => selectAspect(box ?? selection, ratio)); + setPreset(value); + setLocked(true); + }; + return ( + { + if (!confirmLoading) onClose(); + }} + onOk={() => { + if (size && !confirmLoading) onConfirm(selection); + }} + okText="确认裁剪" + confirmLoading={confirmLoading} + okButtonProps={{ disabled: !size || !!error || confirmLoading }} + cancelButtonProps={{ disabled: confirmLoading }} + closable={!confirmLoading} + keyboard={!confirmLoading} + mask={{ closable: !confirmLoading }} + > +
+
+
{ + const drag = gesture.current; + if (confirmLoading || !drag || drag.pointer !== event.pointerId) return; + const dx = (event.clientX - drag.origin[0]) / drag.display[0]; + const dy = (event.clientY - drag.origin[1]) / drag.display[1]; + setSelection(drag.grip ? resizeSelection(drag.selection, drag.grip, dx, dy, aspectLocked) : translateSelection(drag.selection, dx, dy)); + }} + onPointerUp={finish} + onPointerCancel={finish} + onLostPointerCapture={finish} + > + 待裁剪图片 + {size ? ( +
begin(event, null)} + onKeyDown={(event) => { + const vectors: Record = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; + const direction = vectors[event.key]; + if (!direction || confirmLoading) return; + event.preventDefault(); + event.stopPropagation(); + const step = event.shiftKey ? 10 : 1; + setSelection((box) => translateSelection(box ?? selection, (direction[0] * step) / size.width, (direction[1] * step) / size.height)); + }} + > + + {grips.map(({ name, vector }) => ( +
+ ) : null} +
+
+ {fixedAspect === undefined ? ( +
+ {["原图", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16"].map((value) => ( + + ))} +
+ ) : null} +
+ {outputLabel} +
+ {fixedAspect === undefined ? ( + + ) : null} + +
+
+ {error ? ( +

+ {error} +

+ ) : null} +
+
+ ); +} diff --git a/web/src/components/layout/user-avatar.tsx b/web/src/components/layout/user-avatar.tsx index d763dbcf1..008d949c8 100644 --- a/web/src/components/layout/user-avatar.tsx +++ b/web/src/components/layout/user-avatar.tsx @@ -1,11 +1,26 @@ import { useEffect, useState } from "react"; -import { CircleUserRound } from "lucide-react"; +import { Bot, Camera, Cat, CircleUserRound, Clapperboard, Rabbit, Rocket, Sparkles, type LucideIcon } from "lucide-react"; import { cn } from "@/lib/utils"; +import { isDefaultUserAvatarID, type DefaultUserAvatarID } from "@/lib/default-user-avatars"; +import { resolveUserAvatarURL } from "@/lib/user-avatar-url"; +import { apiBaseURL } from "@/services/api/request"; import type { LocalUser } from "@/stores/use-user-store"; -export function UserAvatar({ user, className }: { user: LocalUser; className?: string }) { +const defaultAvatarIcons: Record = { + person: CircleUserRound, + director: Clapperboard, + camera: Camera, + cat: Cat, + rabbit: Rabbit, + robot: Bot, + rocket: Rocket, + sparkles: Sparkles, +}; + +export function UserAvatar({ user, className, iconClassName }: { user: LocalUser; className?: string; iconClassName?: string }) { const [failed, setFailed] = useState(false); - const avatarUrl = /^https?:\/\//i.test(user.avatarUrl || "") ? user.avatarUrl : ""; + const avatarUrl = user.avatarMode === "default" ? "" : resolveUserAvatarURL(user.avatarUrl, apiBaseURL); + const DefaultAvatarIcon = defaultAvatarIcons[isDefaultUserAvatarID(user.avatarPreset) ? user.avatarPreset : "person"]; useEffect(() => setFailed(false), [avatarUrl]); @@ -15,7 +30,7 @@ export function UserAvatar({ user, className }: { user: LocalUser; className?: s {avatarUrl && !failed ? ( setFailed(true)} /> ) : ( - + )} ); diff --git a/web/src/components/layout/workspace-account-card.tsx b/web/src/components/layout/workspace-account-card.tsx index 4c8430d23..938fa1192 100644 --- a/web/src/components/layout/workspace-account-card.tsx +++ b/web/src/components/layout/workspace-account-card.tsx @@ -25,7 +25,7 @@ export function WorkspaceAccountCard({ onWallet, onNavigate }: { onWallet: () => {availableMicrocredits === null ? : } : null} diff --git a/web/src/components/layout/workspace-sidebar-footer.tsx b/web/src/components/layout/workspace-sidebar-footer.tsx index fb1134ec1..3af0dc21d 100644 --- a/web/src/components/layout/workspace-sidebar-footer.tsx +++ b/web/src/components/layout/workspace-sidebar-footer.tsx @@ -1,11 +1,12 @@ import { App, Popover } from "antd"; import { Switch } from "@/components/ui/base/switch"; -import { ChevronRight, CircleUserRound, LogIn, LogOut, Moon, ShieldCheck, Sun } from "lucide-react"; -import { useEffect, useState, type ReactNode } from "react"; +import { ChevronRight, LogIn, LogOut, Moon, ShieldCheck, Sun } from "lucide-react"; +import { useState, type ReactNode } from "react"; import { Link, useNavigate } from "react-router"; import { AppChangelogButton } from "@/components/layout/app-changelog-modal"; import { IdentityProviderBadge } from "@/components/layout/identity-provider-badge"; +import { UserAvatar } from "@/components/layout/user-avatar"; import { SystemAnnouncementCenter } from "@/components/layout/system-announcement-center"; import { useWalletBalance } from "@/hooks/use-wallet-balance"; import { applyUserSession } from "@/lib/user-session"; @@ -69,7 +70,7 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName, content={(
- +
{user.displayName || user.username}
{creditsEnabled ?
可用 {balance} 积分
: null} @@ -96,7 +97,7 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName, )} > diff --git a/web/src/hooks/use-image-tool-source.ts b/web/src/hooks/use-image-tool-source.ts new file mode 100644 index 000000000..5013d2b99 --- /dev/null +++ b/web/src/hooks/use-image-tool-source.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; +import { loadRasterImage } from "@/lib/image-raster"; +import type { PixelSize } from "@/lib/canvas/image-operation-plan"; + +export function useImageToolSource(source: string) { + const [result, setResult] = useState<{ source: string; size: PixelSize | null; error: string }>({ source, size: null, error: "" }); + useEffect(() => { + const request = new AbortController(); + setResult({ source, size: null, error: "" }); + void loadRasterImage(source, request.signal).then( + (image) => { + if (!request.signal.aborted) setResult({ source, size: { width: image.naturalWidth, height: image.naturalHeight }, error: "" }); + }, + (error: unknown) => { + if (!request.signal.aborted) setResult({ source, size: null, error: error instanceof Error ? error.message : "图片读取失败" }); + }, + ); + return () => request.abort(); + }, [source]); + return result.source === source ? result : { source, size: null, error: "" }; +} diff --git a/web/src/lib/canvas/image-crop-selection.ts b/web/src/lib/canvas/image-crop-selection.ts index ae3ec6b23..8d48e5fd0 100644 --- a/web/src/lib/canvas/image-crop-selection.ts +++ b/web/src/lib/canvas/image-crop-selection.ts @@ -1,8 +1,17 @@ -import type { ImageCropRect } from "./image-operation-plan"; +import { assertPixelSize, type ImageCropRect, type PixelSize } from "./image-operation-plan"; export type CropGrip = readonly [-1 | 0 | 1, -1 | 0 | 1]; const limit = (n: number, a: number, b: number) => Math.max(a, Math.min(b, n)); const minimum = 0.06; +export function centeredAspectSelection(size: PixelSize, aspect: number): ImageCropRect { + assertPixelSize(size); + if (!Number.isFinite(aspect) || aspect <= 0) throw new RangeError("裁剪比例无效"); + const ratio = (aspect * size.height) / size.width; + const height = Math.min(1, 1 / ratio); + const width = height * ratio; + return { x: (1 - width) / 2, y: (1 - height) / 2, width, height }; +} + export function translateSelection(box: ImageCropRect, dx: number, dy: number): ImageCropRect { return { width: box.width, height: box.height, x: limit(box.x + dx, 0, 1 - box.width), y: limit(box.y + dy, 0, 1 - box.height) }; } @@ -29,7 +38,8 @@ export function resizeSelection(box: ImageCropRect, grip: CropGrip, dx: number, const proposed = 1 + (dx * vx + dy * vy) / (vx * vx + vy * vy); const capacity = (anchor: number, fraction: number) => Math.min(fraction ? anchor / fraction : Infinity, fraction < 1 ? (1 - anchor) / (1 - fraction) : Infinity); const ceiling = Math.min(capacity(ax, fx) / box.width, capacity(ay, fy) / box.height); - const floor = Math.min(ceiling, Math.max(minimum / box.width, minimum / box.height)); + // 以较长的归一化边限制最小尺寸,避免极宽/高原图的窄轴把缩小比例锁死。 + const floor = Math.min(ceiling, minimum / Math.max(box.width, box.height)); const factor = limit(proposed, floor, ceiling); const width = box.width * factor; const height = box.height * factor; diff --git a/web/src/lib/default-user-avatars.ts b/web/src/lib/default-user-avatars.ts new file mode 100644 index 000000000..6d2209432 --- /dev/null +++ b/web/src/lib/default-user-avatars.ts @@ -0,0 +1,16 @@ +export const DEFAULT_USER_AVATARS = [ + { id: "person", label: "默认人像" }, + { id: "director", label: "导演" }, + { id: "camera", label: "摄影师" }, + { id: "cat", label: "小猫" }, + { id: "rabbit", label: "小兔" }, + { id: "robot", label: "机器人" }, + { id: "rocket", label: "探索者" }, + { id: "sparkles", label: "灵感" }, +] as const; + +export type DefaultUserAvatarID = (typeof DEFAULT_USER_AVATARS)[number]["id"]; + +export function isDefaultUserAvatarID(value: unknown): value is DefaultUserAvatarID { + return DEFAULT_USER_AVATARS.some((avatar) => avatar.id === value); +} diff --git a/web/src/lib/settings-navigation.ts b/web/src/lib/settings-navigation.ts index a07b9e862..d4d10cea7 100644 --- a/web/src/lib/settings-navigation.ts +++ b/web/src/lib/settings-navigation.ts @@ -1,7 +1,7 @@ -export type SettingsSection = "channels" | "models" | "preferences" | "prompts" | "storage"; +export type SettingsSection = "profile" | "channels" | "models" | "preferences" | "prompts" | "storage"; -export function settingsPath(section: SettingsSection = "channels", continueCreation = false) { - const params = new URLSearchParams({ section }); +export function settingsPath(section?: SettingsSection, continueCreation = false) { + const params = new URLSearchParams({ section: section ?? (continueCreation ? "channels" : "profile") }); if (continueCreation) params.set("continue", "1"); return `/settings?${params.toString()}`; } diff --git a/web/src/lib/user-avatar-url.ts b/web/src/lib/user-avatar-url.ts new file mode 100644 index 000000000..0684ea364 --- /dev/null +++ b/web/src/lib/user-avatar-url.ts @@ -0,0 +1,8 @@ +// Profile responses use a canonical /api path; deployment routing belongs to +// the configured API base, including its origin and reverse-proxy prefix. +export function resolveUserAvatarURL(candidate: string | undefined, apiBaseURL: string): string { + if (!candidate) return ""; + if (/^https?:\/\//i.test(candidate)) return candidate; + if (!/^\/api\/auth\/profile\/avatar\/[a-zA-Z0-9_-]+\?v=[a-zA-Z0-9_-]+$/.test(candidate)) return ""; + return `${apiBaseURL.replace(/\/+$/, "")}${candidate.slice("/api".length)}`; +} diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx index 997fba222..4a85b2211 100644 --- a/web/src/pages/admin/components/admin-shell.tsx +++ b/web/src/pages/admin/components/admin-shell.tsx @@ -84,6 +84,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> = label: "用户与运营", items: [ { path: "/admin/users", label: "用户管理", description: "账号、角色与状态", icon: }, + { path: "/admin/login-logs", label: "用户登录日志", description: "登录、失败与主动退出", icon: }, { path: "/admin/announcements", label: "系统公告", description: "发布、关闭与历史公告", icon: }, { path: "/admin/banner-announcements", label: "常驻通知", description: "首页顶部常驻滚动通知", icon: }, { path: "/admin/agent-lessons", label: "Agent 记忆", description: "按用户查看个人记忆", icon: }, diff --git a/web/src/pages/admin/login-logs/login-log-detail-drawer.tsx b/web/src/pages/admin/login-logs/login-log-detail-drawer.tsx new file mode 100644 index 000000000..4a9c90201 --- /dev/null +++ b/web/src/pages/admin/login-logs/login-log-detail-drawer.tsx @@ -0,0 +1,75 @@ +import { Alert, Button, Skeleton } from "antd"; +import dayjs from "dayjs"; +import { useEffect, useState } from "react"; +import { AppDrawer } from "@/components/ui/product/app-drawer/app-drawer"; +import { getAdminLoginLog, type LoginLog } from "@/services/api/login-logs"; +import { loginEventLabels, loginLogDevice, loginMethodLabels, loginSourceLabels } from "./login-log-display"; + +export function LoginLogDetailDrawer({ id, onClose }: { id: string | null; onClose: () => void }) { + const [detail, setDetail] = useState(null); + const [error, setError] = useState(""); + const [retry, setRetry] = useState(0); + useEffect(() => { + setDetail(null); + setError(""); + if (!id) return; + const controller = new AbortController(); + void getAdminLoginLog(id, controller.signal) + .then(({ log }) => { + if (!controller.signal.aborted) setDetail(log); + }) + .catch((cause) => { + if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : "读取日志详情失败"); + }); + return () => controller.abort(); + }, [id, retry]); + const device = detail ? loginLogDevice(detail.userAgent) : null; + const entries = detail + ? [ + ["时间", dayjs(detail.createdAt).format("YYYY-MM-DD HH:mm:ss.SSS Z")], + ["用户昵称", detail.displayName || "未关联用户"], + ["账号", detail.username || detail.account || "未识别"], + ["用户 ID", detail.userId || "未关联用户"], + ["事件", loginEventLabels[detail.event]], + ["来源", loginSourceLabels[detail.source]], + ["登录方式", loginMethodLabels[detail.method]], + ["结果", detail.result === "success" ? "成功" : "失败"], + ["IP", detail.ip || "未记录"], + ["浏览器", device?.browser || "未记录"], + ["操作系统", device?.os || "未记录"], + ["客户端自报信息", detail.userAgent || "未记录"], + ["失败原因", detail.message || "无"], + ["原因码", detail.reason || "无"], + ["关联会话标识", detail.sessionRef || "未关联会话"], + ["日志 ID", detail.id], + ] + : []; + return ( + + {error ? ( + setRetry((value) => value + 1)}> + 重试 + + } + /> + ) : !detail ? ( + + ) : ( +
+ {entries.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ )} +
+ ); +} diff --git a/web/src/pages/admin/login-logs/login-log-display.ts b/web/src/pages/admin/login-logs/login-log-display.ts new file mode 100644 index 000000000..7e03603d2 --- /dev/null +++ b/web/src/pages/admin/login-logs/login-log-display.ts @@ -0,0 +1,28 @@ +export const loginMethodLabels: Record = { password: "密码", email: "邮箱验证码", sms: "短信验证码", linuxdo: "Linux.do", verification: "验证码(方式未识别)", unknown: "未记录" }; +export const loginEventLabels: Record = { login: "登录", logout: "主动退出" }; +export const loginSourceLabels: Record = { login: "用户登录", registration: "注册后自动登录", logout: "主动退出" }; + +// UA 只用于描述客户端自报信息,不能作为身份或安全判断依据。 +export function loginLogDevice(userAgent: string) { + if (!userAgent) return { browser: "未记录", os: "未记录" }; + const browsers: Array<[RegExp, string]> = [ + [/(?:Edg|EdgA|EdgiOS)\/(\d+)/, "Edge"], + [/OPR\/(\d+)/, "Opera"], + [/(?:Firefox|FxiOS)\/(\d+)/, "Firefox"], + [/(?:Chrome|CriOS)\/(\d+)/, "Chrome"], + [/Version\/(\d+).*Safari\//, "Safari"], + ]; + let browser = "未识别浏览器"; + for (const [pattern, name] of browsers) { + const match = userAgent.match(pattern); + if (match) { + browser = `${name} ${match[1]}`; + break; + } + } + const android = userAgent.match(/Android ([\d.]+)/); + const ios = userAgent.match(/(?:iPhone|iPad|iPod).*OS ([\d_]+)/); + const mac = userAgent.match(/Mac OS X ([\d_]+)/); + const os = android ? `Android ${android[1]}` : ios ? `iOS ${ios[1].replaceAll("_", ".")}` : /Windows NT/.test(userAgent) ? "Windows" : mac ? `macOS ${mac[1].replaceAll("_", ".")}` : /Linux/.test(userAgent) ? "Linux" : "未识别系统"; + return { browser, os }; +} diff --git a/web/src/pages/admin/login-logs/login-logs-page.css b/web/src/pages/admin/login-logs/login-logs-page.css new file mode 100644 index 000000000..c219fad64 --- /dev/null +++ b/web/src/pages/admin/login-logs/login-logs-page.css @@ -0,0 +1,68 @@ +[data-admin-root] .admin-list-toolbar:has(.admin-login-log-filters) { + container-name: admin-login-log-toolbar; + container-type: inline-size; +} + +[data-admin-root] .admin-list-toolbar-main:has(> .admin-login-log-filters) { + width: 100%; + min-width: 0; + flex: 1 1 auto; +} + +[data-admin-root] .admin-list-toolbar:has(.admin-login-log-filters) > .admin-list-toolbar-actions:empty { + display: none; +} + +.admin-login-log-filters { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2); + align-items: center; + width: 100%; + min-width: 0; +} + +.admin-login-log-filters > * { + width: 100% !important; + min-width: 0 !important; + max-width: none; +} + +.admin-login-log-search, +.admin-login-log-dates { + grid-column: 1 / -1; +} + +.admin-login-log-filters > .admin-login-log-reset { + grid-column: 2; + justify-self: end; + width: auto !important; +} + +@container admin-login-log-toolbar (min-width: 640px) { + .admin-login-log-filters { + grid-template-columns: minmax(180px, 1.7fr) minmax(100px, 1fr) minmax(128px, 1.2fr) minmax(88px, 1fr); + } + + .admin-login-log-search { + grid-column: auto; + } + + .admin-login-log-dates { + grid-column: span 2; + } + + .admin-login-log-filters > .admin-login-log-reset { + grid-column: auto; + } +} + +@container admin-login-log-toolbar (min-width: 1080px) { + .admin-login-log-filters { + grid-template-columns: minmax(180px, 1fr) 104px 140px 96px minmax(120px, 0.8fr) minmax(300px, 1.6fr) 80px; + } + + .admin-login-log-dates { + grid-column: auto; + } +} diff --git a/web/src/pages/admin/login-logs/login-logs-page.tsx b/web/src/pages/admin/login-logs/login-logs-page.tsx new file mode 100644 index 000000000..821c7d650 --- /dev/null +++ b/web/src/pages/admin/login-logs/login-logs-page.tsx @@ -0,0 +1,222 @@ +import { Alert, Button, DatePicker } from "antd"; +import type { ColumnsType } from "antd/es/table"; +import dayjs from "dayjs"; +import { RefreshCw, RotateCcw, Search } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useSearchParams } from "react-router"; + +import { Select } from "@/components/ui/base/select"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { listAdminLoginLogs, type LoginLog } from "@/services/api/login-logs"; +import { AdminPageFrame } from "../components/admin-shell"; +import { AdminSearchInput } from "../components/admin-search-input"; +import { AdminDataTable, AdminStatusBadge, AdminTableEmpty, PaginationBar } from "../components/admin-ui"; +import { loginEventLabels, loginLogDevice, loginMethodLabels, loginSourceLabels } from "./login-log-display"; +import { LoginLogDetailDrawer } from "./login-log-detail-drawer"; +import "./login-logs-page.css"; + +export default function LoginLogsPage() { + const [searchParams, setSearchParams] = useSearchParams(); + const keyword = searchParams.get("keyword") || ""; + const event = searchParams.get("event") || ""; + const method = searchParams.get("method") || ""; + const result = searchParams.get("result") || ""; + const ip = searchParams.get("ip") || ""; + const from = searchParams.get("from") || ""; + const to = searchParams.get("to") || ""; + const rawPage = Number(searchParams.get("page") || 1); + const page = Number.isSafeInteger(rawPage) && rawPage > 0 ? rawPage : 1; + const rawSize = Number(searchParams.get("pageSize") || 20); + const pageSize = [20, 50, 100].includes(rawSize) ? rawSize : 20; + const debouncedKeyword = useDebouncedValue(keyword); + const debouncedIP = useDebouncedValue(ip); + const [logs, setLogs] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [retry, setRetry] = useState(0); + const [detailId, setDetailId] = useState(null); + const filtered = Boolean(keyword || event || method || result || ip || from || to); + + const update = (patch: Record, replace = false) => { + const next = new URLSearchParams(searchParams); + for (const [key, value] of Object.entries(patch)) { + if (value === "" || (key === "page" && value === 1) || (key === "pageSize" && value === 20)) next.delete(key); + else next.set(key, String(value)); + } + setSearchParams(next, { replace }); + }; + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(""); + setLogs([]); + setTotal(0); + void listAdminLoginLogs( + { keyword: debouncedKeyword || undefined, event: event || undefined, method: method || undefined, result: result || undefined, ip: debouncedIP || undefined, from: from || undefined, to: to || undefined, page, pageSize }, + controller.signal, + ) + .then((data) => { + if (controller.signal.aborted) return; + setLogs(data.logs); + setTotal(data.total); + if (page > 1 && data.logs.length === 0) update({ page: 1 }, true); + }) + .catch((cause) => { + if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : "读取登录日志失败"); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [debouncedKeyword, event, method, result, debouncedIP, from, to, page, pageSize, retry]); + + const columns: ColumnsType = [ + { + title: "时间", + width: 180, + render: (_, log) => ( + + ), + }, + { + title: "用户 / 账号", + width: 190, + render: (_, log) => ( +
+
{log.displayName || log.username || "未关联用户"}
+
{log.username ? `@${log.username}` : log.account || "账号未识别"}
+
+ ), + }, + { + title: "事件", + width: 160, + render: (_, log) => ( +
+
{loginEventLabels[log.event]}
+ {log.source === "registration" ?
{loginSourceLabels[log.source]}
: null} +
+ ), + }, + { title: "登录方式", width: 165, render: (_, log) => loginMethodLabels[log.method] }, + { title: "结果", width: 92, render: (_, log) => }, + { title: "IP", dataIndex: "ip", width: 175, render: (value: string) => {value || "未记录"} }, + { + title: "浏览器 / 系统", + width: 190, + render: (_, log) => { + const device = loginLogDevice(log.userAgent); + return ( +
+
{device.browser}
+
{device.os}
+
+ ); + }, + }, + { + title: "失败原因", + width: 250, + render: (_, log) => + log.result === "failed" ? ( +
+ {log.message} +
+ ) : ( + — + ), + }, + ]; + + return ( + } loading={loading} onClick={() => setRetry((value) => value + 1)}> + 刷新 + + } + > + {error ? ( + setRetry((value) => value + 1)}> + 重试 + + } + /> + ) : null} + + toolbar={ +
+ } + value={keyword} + placeholder="搜索用户昵称、账号或 ID" + aria-label="搜索登录日志用户" + onValueChange={(value) => update({ keyword: value, page: 1 }, true)} + /> + ({ value, label }))]} + onChange={(value) => update({ method: value, page: 1 })} + /> + + +
+ + + + +
+ {emailTicket ? ( +

+ {ticketSeconds ? `验证码已发送,${Math.ceil(ticketSeconds / 60)} 分钟内有效。` : "验证码已过期,请重新发送。"} +

+ ) : null} + {errors.email ? ( + + {errors.email} + + ) : null} + + + )} + +
+
+ ) : null} + {profile ? ( + void (avatarTab === "upload" ? saveAvatar() : saveDefaultAvatar())} + > +
+ + block + ariaLabel="头像来源" + value={avatarTab} + disabled={!!pending} + options={[ + { value: "upload", label: "上传图片" }, + { value: "preset", label: "系统头像" }, + ]} + onChange={(tab) => { + setAvatarTab(tab); + setError("avatar", ""); + }} + /> + {avatarTab === "upload" ? ( +
+ {draftAvatar ? ( + 待保存的头像预览 { + setDraftAvatar(null); + setError("avatar", "无法读取图片,请选择有效的 PNG 或 JPEG 图片"); + }} + /> + ) : ( + + )} +
+

{draftAvatar ? "裁剪后的头像" : "当前头像"}

+

选择 PNG 或 JPEG 图片,可裁剪为正方形;文件最大 5 MiB。

+ +
+ + {draftAvatar ? ( + + ) : null} +
+
+
+ ) : ( + <> +

选择一款系统头像,保存后会同步到工作区。

+
+ {DEFAULT_USER_AVATARS.map((preset) => ( + + ))} +
+ + )} + {errors.avatar ? ( + + {errors.avatar} + + ) : null} +
+
+ ) : null} + {cropSource ? ( + { + if (!pendingRef.current) setCropSource(null); + }} + onConfirm={(crop) => void confirmAvatarCrop(crop)} + /> + ) : null} +
+ ); +} diff --git a/web/src/pages/settings/profile-validation.ts b/web/src/pages/settings/profile-validation.ts new file mode 100644 index 000000000..6a4f26a12 --- /dev/null +++ b/web/src/pages/settings/profile-validation.ts @@ -0,0 +1,20 @@ +export const PROFILE_AVATAR_MAX_BYTES = 5 * 1024 * 1024; + +export function profileAvatarError(file: Pick): string | undefined { + if (file.type !== "image/png" && file.type !== "image/jpeg") return "请选择 PNG 或 JPEG 图片"; + if (!file.size) return "图片文件不能为空"; + if (file.size > PROFILE_AVATAR_MAX_BYTES) return "图片大小不能超过 5 MB"; +} + +export function profilePasswordError(password: string): string | undefined { + if ([...password].length < 8) return "密码至少需要 8 个字符"; + if (new TextEncoder().encode(password).length > 72) return "密码不能超过 72 字节,请缩短密码"; +} + +export function profileSecondsRemaining(until: number, now: number): number { + return Math.max(0, Math.ceil((until - now) / 1000)); +} + +export function profileReauthReady(until: string | undefined, now: number): boolean { + return !!until && Date.parse(until) > now; +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 43184dd80..f9f88da53 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -25,6 +25,7 @@ const AdminPluginsPage = lazy(() => import("@/pages/admin/plugins/plugins-page") const SkillCurationPage = lazy(() => import("@/pages/admin/skill-curation-page")); const AdminPaymentsPage = lazy(() => import("@/pages/admin/payments/payments-page")); const LogsPage = lazy(() => import("@/pages/admin/logs/logs-page")); +const LoginLogsPage = lazy(() => import("@/pages/admin/login-logs/login-logs-page")); const RedemptionCodesPage = lazy(() => import("@/pages/admin/redemption-codes/redemption-codes-page")); const RuntimePolicySettingsPage = lazy(() => import("@/pages/admin/settings/runtime-policy-settings-page")); const AppearanceSettingsPage = lazy(() => import("@/pages/admin/settings/appearance-settings-page")); @@ -189,6 +190,7 @@ export const router = createBrowserRouter([ children: [ { index: true, element: }, { path: "users", element: }, + { path: "login-logs", element: }, { path: "channels", element: }, { path: "models", element: }, { path: "plugins", element: }, diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts index 99aaabfe2..804a3c53f 100644 --- a/web/src/services/api/auth.ts +++ b/web/src/services/api/auth.ts @@ -23,6 +23,8 @@ export type LocalUser = { phoneVerifiedAt?: string; displayName: string; avatarUrl?: string; + avatarMode?: "custom" | "default" | ""; + avatarPreset?: string; identityProvider?: string; identityId?: string; identityUsername?: string; diff --git a/web/src/services/api/login-logs.ts b/web/src/services/api/login-logs.ts new file mode 100644 index 000000000..641f4bc97 --- /dev/null +++ b/web/src/services/api/login-logs.ts @@ -0,0 +1,39 @@ +import { compactApiParams, http } from "./request"; + +export type LoginLog = { + id: string; + userId: string | null; + username: string; + displayName: string; + account: string; + event: "login" | "logout"; + method: "password" | "email" | "sms" | "linuxdo" | "verification" | "unknown"; + source: "login" | "registration" | "logout"; + result: "success" | "failed"; + ip: string; + userAgent: string; + sessionRef: string; + reason: string; + message: string; + createdAt: string; +}; + +export type LoginLogParams = { + keyword?: string; + event?: string; + method?: string; + result?: string; + ip?: string; + from?: string; + to?: string; + page?: number; + pageSize?: number; +}; + +export function listAdminLoginLogs(params: LoginLogParams, signal?: AbortSignal) { + return http.get<{ logs: LoginLog[]; total: number; page: number; pageSize: number }>("/admin/login-logs", { params: compactApiParams(params), signal }); +} + +export function getAdminLoginLog(id: string, signal?: AbortSignal) { + return http.get<{ log: LoginLog }>(`/admin/login-logs/${encodeURIComponent(id)}`, { signal }); +} diff --git a/web/src/services/api/profile.ts b/web/src/services/api/profile.ts new file mode 100644 index 000000000..347a3132b --- /dev/null +++ b/web/src/services/api/profile.ts @@ -0,0 +1,29 @@ +import type { LocalUser } from "./auth"; +import { apiBaseURL, http } from "./request"; + +export type UserProfile = { + user: LocalUser; + hasPassword: boolean; + emailEnabled: boolean; + linuxdoEnabled: boolean; + reauthenticatedUntil?: string; +}; + +export type ProfileEmailTicket = { ticket: string; expiresIn: number; retryAfter: number }; +export type ProfileEmailResult = { changed: true; notificationStatus: "sent" | "failed" | "not_needed" }; + +export const getUserProfile = (signal?: AbortSignal) => http.get("/auth/profile", { signal }); + +export function uploadProfileAvatar(file: File) { + const data = new FormData(); + data.append("file", file); + return http.put<{ user: LocalUser }>("/auth/profile/avatar", data); +} + +export const resetProfileAvatar = () => http.delete<{ user: LocalUser }>("/auth/profile/avatar"); +export const selectDefaultProfileAvatar = (preset: string) => http.post<{ user: LocalUser }>("/auth/profile/avatar/default", { preset }); +export const changeProfilePassword = (input: { currentPassword: string; newPassword: string }) => http.post<{ changed: true }>("/auth/profile/password", input); +export const requestProfileEmailCode = (input: { email: string; currentPassword: string }) => http.post("/auth/profile/email-code", input); +export const changeProfileEmail = (input: { ticket: string; emailCode: string; currentPassword: string }) => http.post("/auth/profile/email", input); + +export const profileLinuxDOReauthURL = () => `${apiBaseURL.replace(/\/$/, "")}/auth/profile/linuxdo/start`; diff --git a/web/src/services/profile-avatar.ts b/web/src/services/profile-avatar.ts new file mode 100644 index 000000000..70e68ce07 --- /dev/null +++ b/web/src/services/profile-avatar.ts @@ -0,0 +1,42 @@ +import { assertPixelSize, resolveCropPixels, type ImageCropRect, type PixelSize } from "@/lib/canvas/image-operation-plan"; +import { loadRasterImage } from "@/lib/image-raster"; + +const MAX_AVATAR_PIXELS = 16_000_000; +const MAX_AVATAR_SIDE = 512; + +export function planProfileAvatarCrop(size: PixelSize, crop: ImageCropRect): { source: ImageCropRect; outputSide: number } { + assertPixelSize(size); + if (size.width * size.height > MAX_AVATAR_PIXELS) throw new RangeError("图片像素不能超过 1600 万,请先缩小图片"); + const source = resolveCropPixels(size, crop); + if (crop.x < -1e-12 || crop.y < -1e-12 || crop.x + crop.width > 1 + 1e-12 || crop.y + crop.height > 1 + 1e-12) throw new RangeError("裁剪区域超出图片边界"); + if (Math.abs(crop.width * size.width - crop.height * size.height) > 1e-6) throw new RangeError("头像裁剪区域必须为正方形"); + // 浮点选区向像素取整可能多出一列或一行,保留选区中心并输出严格方形。 + const side = Math.min(source.width, source.height); + return { source: { x: source.x + Math.floor((source.width - side) / 2), y: source.y + Math.floor((source.height - side) / 2), width: side, height: side }, outputSide: Math.min(side, MAX_AVATAR_SIDE) }; +} + +export async function cropProfileAvatar(sourceURL: string, crop: ImageCropRect, _originalName: string): Promise { + const image = await loadRasterImage(sourceURL); + let canvas: HTMLCanvasElement | undefined; + try { + const { source, outputSide } = planProfileAvatarCrop({ width: image.naturalWidth, height: image.naturalHeight }, crop); + canvas = document.createElement("canvas"); + canvas.width = outputSide; + canvas.height = outputSide; + const context = canvas.getContext("2d"); + if (!context) throw new Error("浏览器无法创建头像裁剪画布"); + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = "high"; + context.drawImage(image, source.x, source.y, source.width, source.height, 0, 0, outputSide, outputSide); + const blob = await new Promise((resolve, reject) => { + canvas!.toBlob((result) => (result && result.size > 0 ? resolve(result) : reject(new Error("头像 PNG 导出失败,请重试"))), "image/png"); + }); + return new File([blob], "avatar.png", { type: "image/png" }); + } finally { + image.src = ""; + if (canvas) { + canvas.width = 0; + canvas.height = 0; + } + } +} diff --git a/web/src/services/profile-session.ts b/web/src/services/profile-session.ts new file mode 100644 index 000000000..06752f572 --- /dev/null +++ b/web/src/services/profile-session.ts @@ -0,0 +1,12 @@ +import { invalidateAuthSessionCache } from "@/services/api/auth"; +import { getUserProfile } from "@/services/api/profile"; +import { useUserStore } from "@/stores/use-user-store"; + +export async function loadProfileSession(userId: string | undefined, signal: AbortSignal) { + const profile = await getUserProfile(signal); + if (!signal.aborted && useUserStore.getState().user?.id === userId && profile.user.id === userId) { + invalidateAuthSessionCache(); + useUserStore.getState().setUser(profile.user); + } + return profile; +} diff --git a/web/src/stores/use-user-store.ts b/web/src/stores/use-user-store.ts index a7e5f0375..8c9dd5261 100644 --- a/web/src/stores/use-user-store.ts +++ b/web/src/stores/use-user-store.ts @@ -6,8 +6,13 @@ export type LocalUser = { id: string; username: string; email?: string; + phone?: string; + emailVerifiedAt?: string; + phoneVerifiedAt?: string; displayName: string; avatarUrl?: string; + avatarMode?: "custom" | "default" | ""; + avatarPreset?: string; identityProvider?: string; identityId?: string; identityUsername?: string; diff --git a/web/test/admin-ui-regressions.test.ts b/web/test/admin-ui-regressions.test.ts index 3db40e744..759399c7c 100644 --- a/web/test/admin-ui-regressions.test.ts +++ b/web/test/admin-ui-regressions.test.ts @@ -86,10 +86,10 @@ test("channel model fetch requires explicit selection before import", async () = expect(component).toContain("setFetchPreviewOpen(true)"); expect(component).toContain("setSelectedFetchModels(result.models)"); expect(component).toContain("已选择 {selectedFetchModels.length} / {fetchPreviewModels.length} 个模型"); - expect(component).toContain("disabled={importing || allFetchModelsSelected}"); - expect(component).toContain("onClick={() => setSelectedFetchModels(fetchPreviewModels)}"); - expect(component).toContain("disabled={importing || selectedFetchModels.length === 0}"); - expect(component).toContain("onClick={() => setSelectedFetchModels([])}"); + expect(component).toContain("disabled={importing || !visibleFetchModels.length || allVisibleFetchModelsSelected}"); + expect(component).toContain("onClick={() => setSelectedFetchModels((current) => selectVisibleModels(current, visibleFetchModels))}"); + expect(component).toContain("disabled={importing || selectedVisibleFetchCount === 0}"); + expect(component).toContain("onClick={() => setSelectedFetchModels((current) => deselectVisibleModels(current, visibleFetchModels))}"); expect(component).toContain("取消全选"); expect(component).toContain("disabled={importing}"); expect(component).toContain("importAdminChannelModels(channel.id, selectedFetchModels)"); diff --git a/web/test/agent-launcher-position.test.mjs b/web/test/agent-launcher-position.test.mjs index 7401dd33c..a445fc838 100644 --- a/web/test/agent-launcher-position.test.mjs +++ b/web/test/agent-launcher-position.test.mjs @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { fileURLToPath } from "node:url"; import { runInNewContext } from "node:vm"; import { clampAgentLauncherPosition, moveAgentLauncher, restoreAgentLauncherPosition } from "../src/lib/canvas/agent-launcher-position"; @@ -36,7 +37,7 @@ test("small pointer jitter remains a click, but dragging back to the origin rema // Execute the production hook with a tiny hook scheduler; only React and browser // side effects are substituted, not the pointer/click handlers being tested. const build = await Bun.build({ - entrypoints: [new URL("../src/components/canvas/use-agent-launcher-position.ts", import.meta.url).pathname], + entrypoints: [fileURLToPath(new URL("../src/components/canvas/use-agent-launcher-position.ts", import.meta.url))], target: "browser", format: "cjs", plugins: [ diff --git a/web/test/application-bootstrap.test.mjs b/web/test/application-bootstrap.test.mjs index b1c7a894b..83db4cda3 100644 --- a/web/test/application-bootstrap.test.mjs +++ b/web/test/application-bootstrap.test.mjs @@ -1,12 +1,13 @@ import { expect, test } from "bun:test"; import { runInNewContext } from "node:vm"; +import { fileURLToPath } from "node:url"; import { isIsolatedPrevisRepro } from "../src/lib/dev-repro"; // Execute the real entry point; replace only its font, network and UI side effects. async function prepareEntry(dev, pathname) { const build = await Bun.build({ - entrypoints: [new URL("../src/main.tsx", import.meta.url).pathname], + entrypoints: [fileURLToPath(new URL("../src/main.tsx", import.meta.url))], target: "browser", format: "iife", define: { "import.meta.env.DEV": JSON.stringify(dev) }, diff --git a/web/test/login-log-device.test.ts b/web/test/login-log-device.test.ts new file mode 100644 index 000000000..16e102118 --- /dev/null +++ b/web/test/login-log-device.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "bun:test"; +import { loginLogDevice } from "../src/pages/admin/login-logs/login-log-display"; + +describe("登录日志设备识别", () => { + test("Edge 不误判为 Chrome,Android 不误判为 Linux", () => { + expect(loginLogDevice("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0")).toEqual({ browser: "Edge 130", os: "Windows" }); + expect(loginLogDevice("Mozilla/5.0 (Linux; Android 14) Chrome/130.0.0.0 Mobile Safari/537.36")).toEqual({ browser: "Chrome 130", os: "Android 14" }); + }); + test("iPhone 和未知 UA 明确展示识别边界", () => { + expect(loginLogDevice("Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) Version/18.0 Mobile/15E148 Safari/604.1")).toEqual({ browser: "Safari 18", os: "iOS 18.0" }); + expect(loginLogDevice("custom-client")).toEqual({ browser: "未识别浏览器", os: "未识别系统" }); + expect(loginLogDevice("")).toEqual({ browser: "未记录", os: "未记录" }); + }); +}); diff --git a/web/test/profile-avatar-crop.test.ts b/web/test/profile-avatar-crop.test.ts new file mode 100644 index 000000000..2d9bbe193 --- /dev/null +++ b/web/test/profile-avatar-crop.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test"; +import { centeredAspectSelection, resizeSelection, translateSelection, type CropGrip } from "../src/lib/canvas/image-crop-selection"; +import { cropProfileAvatar, planProfileAvatarCrop } from "../src/services/profile-avatar"; + +describe("fixed avatar crop geometry", () => { + test("starts with the largest centered square on wide, tall and odd images", () => { + for (const size of [ + { width: 1200, height: 800 }, + { width: 800, height: 1200 }, + { width: 901, height: 600 }, + ]) { + const crop = centeredAspectSelection(size, 1); + expect(crop.width * size.width).toBeCloseTo(Math.min(size.width, size.height)); + expect(crop.height * size.height).toBeCloseTo(Math.min(size.width, size.height)); + expect(crop.x + crop.width / 2).toBeCloseTo(0.5); + expect(crop.y + crop.height / 2).toBeCloseTo(0.5); + const plan = planProfileAvatarCrop(size, crop); + expect(plan.source.width).toBe(plan.source.height); + expect(plan.outputSide).toBe(512); + } + }); + test("every locked grip and movement preserves physical square pixels", () => { + const size = { width: 1200, height: 600 }; + const initial = centeredAspectSelection(size, 1); + for (const x of [-1, 0, 1] as const) + for (const y of [-1, 0, 1] as const) { + if (!x && !y) continue; + const resized = resizeSelection(initial, [x, y] as CropGrip, -0.1, -0.1, true); + const moved = translateSelection(resized, 0.2, -0.3); + expect(moved.width * size.width).toBeCloseTo(moved.height * size.height); + expect(planProfileAvatarCrop(size, moved).source.width).toBe(planProfileAvatarCrop(size, moved).source.height); + } + }); + test("square crops on panoramic images can shrink from every grip and remain square", () => { + for (const size of [ + { width: 2000, height: 100 }, + { width: 100, height: 2000 }, + ]) { + const initial = centeredAspectSelection(size, 1); + for (const x of [-1, 0, 1] as const) + for (const y of [-1, 0, 1] as const) { + if (!x && !y) continue; + const next = resizeSelection(initial, [x, y], (-x * initial.width) / 4, (-y * initial.height) / 4, true); + expect(next.width * size.width).toBeCloseTo(75); + expect(next.height * size.height).toBeCloseTo(75); + expect(next.x).toBeGreaterThanOrEqual(0); + expect(next.y).toBeGreaterThanOrEqual(0); + expect(next.x + next.width).toBeLessThanOrEqual(1); + expect(next.y + next.height).toBeLessThanOrEqual(1); + } + } + }); + test("one-pixel keyboard steps shrink panoramic squares and enforce a reachable positive minimum", () => { + for (const size of [ + { width: 2000, height: 100 }, + { width: 100, height: 2000 }, + ]) { + const initial = centeredAspectSelection(size, 1); + expect(resizeSelection(initial, [1, 0], -1 / size.width, 0, true).width * size.width).toBeCloseTo(99); + expect(resizeSelection(initial, [0, 1], 0, -1 / size.height, true).height * size.height).toBeCloseTo(99); + const smallest = resizeSelection(initial, [1, 1], -10, -10, true); + expect(smallest.width * size.width).toBeCloseTo(6); + expect(smallest.height * size.height).toBeCloseTo(6); + } + }); + test("rejects decoded images over 16M pixels, invalid and non-square crops", () => { + expect(() => planProfileAvatarCrop({ width: 4001, height: 4000 }, { x: 0, y: 0, width: 1, height: 1 })).toThrow("1600"); + expect(() => planProfileAvatarCrop({ width: 100, height: 100 }, { x: 0, y: 0, width: 1, height: 0.5 })).toThrow("正方形"); + expect(() => centeredAspectSelection({ width: 100, height: 0 }, 1)).toThrow(); + expect(() => centeredAspectSelection({ width: 100, height: 100 }, 0)).toThrow(); + expect(() => planProfileAvatarCrop({ width: 100, height: 100 }, { x: 2, y: 0, width: 1, height: 1 })).toThrow(); + expect(planProfileAvatarCrop({ width: 100, height: 100 }, { x: 0.2, y: 0.3, width: 0.4, height: 0.4 })).toEqual({ source: { x: 20, y: 30, width: 40, height: 40 }, outputSide: 40 }); + }); +}); + +async function withRasterDOM(width: number, height: number, run: (state: { drawn: unknown[][]; imageSources: string[]; canvas: { width: number; height: number }; failExport: () => void }) => Promise) { + const previousImage = globalThis.Image; + const previousDocument = globalThis.document; + const drawn: unknown[][] = []; + const imageSources: string[] = []; + let exportFails = false; + class TestImage { + naturalWidth = width; + naturalHeight = height; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + set src(value: string) { + imageSources.push(value); + if (value) queueMicrotask(() => this.onload?.()); + } + } + const canvas = { width: 0, height: 0, getContext: () => ({ drawImage: (...values: unknown[]) => drawn.push(values) }), toBlob: (callback: (blob: Blob | null) => void) => callback(exportFails ? null : new Blob(["png"], { type: "image/png" })) }; + Object.assign(globalThis, { Image: TestImage, document: { createElement: () => canvas } }); + try { + await run({ + drawn, + imageSources, + canvas, + failExport: () => { + exportFails = true; + }, + }); + } finally { + Object.assign(globalThis, { Image: previousImage, document: previousDocument }); + } +} + +describe("avatar PNG export", () => { + test("draws the chosen source region into a square bounded by 512 and releases buffers", async () => { + await withRasterDOM(1200, 800, async ({ drawn, canvas, imageSources }) => { + const file = await cropProfileAvatar("blob:test", { x: 0.25, y: 0.125, width: 0.5, height: 0.75 }, "../../private.jpg"); + expect(drawn[0].slice(1)).toEqual([300, 100, 600, 600, 0, 0, 512, 512]); + expect(file.name).toBe("avatar.png"); + expect(file.type).toBe("image/png"); + expect(file.size).toBeGreaterThan(0); + expect(canvas.width).toBe(0); + expect(canvas.height).toBe(0); + expect(imageSources.at(-1)).toBe(""); + }); + }); + test("fails explicitly when PNG export fails and still releases image and canvas", async () => { + await withRasterDOM(100, 100, async ({ failExport, canvas, imageSources }) => { + failExport(); + await expect(cropProfileAvatar("blob:test", { x: 0, y: 0, width: 1, height: 1 }, "avatar.jpg")).rejects.toThrow("PNG"); + expect(canvas.width).toBe(0); + expect(imageSources.at(-1)).toBe(""); + }); + }); + test("rejects pixel bombs before drawing", async () => { + await withRasterDOM(5000, 5000, async ({ drawn, imageSources }) => { + await expect(cropProfileAvatar("blob:test", { x: 0, y: 0, width: 1, height: 1 }, "avatar.jpg")).rejects.toThrow("1600"); + expect(drawn).toHaveLength(0); + expect(imageSources.at(-1)).toBe(""); + }); + }); +}); diff --git a/web/test/profile-session.test.ts b/web/test/profile-session.test.ts new file mode 100644 index 000000000..a038da13a --- /dev/null +++ b/web/test/profile-session.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from "bun:test"; +import { QueryClient } from "@tanstack/react-query"; +import { apiClient } from "../src/services/api/request"; +import { loadProfileSession } from "../src/services/profile-session"; +import { useUserStore, type LocalUser } from "../src/stores/use-user-store"; + +const originalUser: LocalUser = { id: "profile-user", username: "user", displayName: "User", role: "user", status: "active", avatarMode: "default", avatarPreset: "person" }; + +test("profile refresh synchronizes avatars while rejecting stale account and cancelled responses", async () => { + const originalRequest = apiClient.request; + const previousUser = useUserStore.getState().user; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + try { + for (const scenario of ["refresh", "switched-account", "wrong-user", "cancelled", "failure"] as const) { + useUserStore.getState().setUser(originalUser); + let respond!: (value: unknown) => void; + let reject!: (reason: Error) => void; + apiClient.request = (() => + new Promise((resolve, fail) => { + respond = resolve; + reject = fail; + })) as typeof apiClient.request; + const key = ["user-profile", scenario]; + const request = client.fetchQuery({ queryKey: key, queryFn: ({ signal }) => loadProfileSession(originalUser.id, signal) }).catch((error) => error); + if (scenario === "switched-account") useUserStore.getState().setUser({ ...originalUser, id: "other-user" }); + if (scenario === "cancelled") { + await client.cancelQueries({ queryKey: key }); + useUserStore.getState().setUser({ ...originalUser, avatarPreset: "cat" }); + } + if (scenario === "failure") reject(new Error("profile unavailable")); + else respond({ data: { code: 0, data: { user: { ...originalUser, id: scenario === "wrong-user" ? "unexpected-user" : originalUser.id, avatarPreset: "robot" } }, msg: "ok" }, status: 200 }); + await request; + await Promise.resolve(); + const stored = useUserStore.getState().user; + expect(stored?.id).toBe(scenario === "switched-account" ? "other-user" : originalUser.id); + expect(stored?.avatarPreset).toBe(scenario === "refresh" ? "robot" : scenario === "cancelled" ? "cat" : "person"); + } + } finally { + await client.cancelQueries(); + client.clear(); + apiClient.request = originalRequest; + useUserStore.setState({ user: previousUser }); + } +}); diff --git a/web/test/profile-settings.test.ts b/web/test/profile-settings.test.ts new file mode 100644 index 000000000..1e17925d5 --- /dev/null +++ b/web/test/profile-settings.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; +import { profileAvatarError, PROFILE_AVATAR_MAX_BYTES, profilePasswordError, profileReauthReady, profileSecondsRemaining } from "../src/pages/settings/profile-validation"; +import { settingsPath } from "../src/lib/settings-navigation"; +import { changeProfileEmail, changeProfilePassword, getUserProfile, profileLinuxDOReauthURL, requestProfileEmailCode, resetProfileAvatar, selectDefaultProfileAvatar, uploadProfileAvatar } from "../src/services/api/profile"; +import { ApiError, apiClient } from "../src/services/api/request"; + +describe("profile input and security expiry", () => { + test("rejects unsupported, empty and oversized avatar uploads", () => { + expect(profileAvatarError({ type: "image/svg+xml", size: 100 })).toBeDefined(); + expect(profileAvatarError({ type: "image/png", size: 0 })).toBeDefined(); + expect(profileAvatarError({ type: "image/jpeg", size: PROFILE_AVATAR_MAX_BYTES + 1 })).toBeDefined(); + expect(profileAvatarError({ type: "image/png", size: PROFILE_AVATAR_MAX_BYTES })).toBeUndefined(); + }); + test("checks password UTF-8 byte limit to prevent bcrypt truncation", () => { + expect(profilePasswordError("1234567")).toBeDefined(); + expect(profilePasswordError("a".repeat(72))).toBeUndefined(); + expect(profilePasswordError("a".repeat(73))).toBeDefined(); + expect(profilePasswordError("密".repeat(24))).toBeUndefined(); + expect(profilePasswordError("密".repeat(25))).toBeDefined(); + expect(profilePasswordError("😀".repeat(4))).toBeDefined(); + expect(profilePasswordError("😀".repeat(8))).toBeUndefined(); + }); + test("expires identity proof and code cooldown at the boundary", () => { + const deadline = "2026-10-12T12:00:00Z"; + const timestamp = Date.parse(deadline); + expect(profileReauthReady(deadline, timestamp - 1)).toBe(true); + expect(profileReauthReady(deadline, timestamp)).toBe(false); + expect(profileReauthReady("invalid", timestamp)).toBe(false); + expect(profileReauthReady(undefined, timestamp)).toBe(false); + expect(profileSecondsRemaining(timestamp, timestamp - 1001)).toBe(2); + expect(profileSecondsRemaining(timestamp, timestamp + 1)).toBe(0); + }); + test("account settings default to profile and creation continues to channels", () => { + expect(settingsPath()).toBe("/settings?section=profile"); + expect(settingsPath(undefined, true)).toBe("/settings?section=channels&continue=1"); + expect(settingsPath("models", true)).toBe("/settings?section=models&continue=1"); + expect(settingsPath("profile")).toBe("/settings?section=profile"); + }); +}); + +describe("profile API request contract", () => { + test("forwards load cancellation and uses multipart avatar without forcing content type", async () => { + const original = apiClient.request; + const requests: Array> = []; + apiClient.request = (async (config: Record) => { + requests.push(config); + return { data: { code: 0, data: { user: { id: "user-1" } }, msg: "ok" }, status: 200 }; + }) as typeof apiClient.request; + try { + const signal = new AbortController().signal; + await getUserProfile(signal); + const file = new File(["image-bytes"], "avatar.png", { type: "image/png" }); + await uploadProfileAvatar(file); + await resetProfileAvatar(); + await selectDefaultProfileAvatar("cat"); + expect(requests[0]).toMatchObject({ url: "/auth/profile", method: "get", signal }); + expect(requests[1]).toMatchObject({ url: "/auth/profile/avatar", method: "put" }); + const multipart = requests[1].data as FormData; + expect(multipart.get("file")).toBeInstanceOf(File); + expect((multipart.get("file") as File).name).toBe("avatar.png"); + expect(requests[1].headers).toBeUndefined(); + expect(requests[2]).toMatchObject({ url: "/auth/profile/avatar", method: "delete" }); + expect(requests[3]).toMatchObject({ url: "/auth/profile/avatar/default", method: "post", data: { preset: "cat" } }); + } finally { + apiClient.request = original; + } + }); + test("keeps passwords, email codes and tickets in POST bodies", async () => { + const original = apiClient.request; + const requests: Array> = []; + apiClient.request = (async (config: Record) => { + requests.push(config); + return { data: { code: 0, data: { changed: true }, msg: "ok" }, status: 200 }; + }) as typeof apiClient.request; + try { + await changeProfilePassword({ currentPassword: "old-password", newPassword: "new-password" }); + await requestProfileEmailCode({ email: "new@example.test", currentPassword: "old-password" }); + await changeProfileEmail({ ticket: "test-ticket", emailCode: "123456", currentPassword: "old-password" }); + expect(requests.map((request) => request.url)).toEqual(["/auth/profile/password", "/auth/profile/email-code", "/auth/profile/email"]); + expect(requests.every((request) => request.method === "post" && request.params === undefined)).toBe(true); + expect(requests[2].data).toEqual({ ticket: "test-ticket", emailCode: "123456", currentPassword: "old-password" }); + expect(profileLinuxDOReauthURL()).toEndWith("/auth/profile/linuxdo/start"); + expect(profileLinuxDOReauthURL()).not.toContain("?"); + } finally { + apiClient.request = original; + } + }); + test("propagates failed security writes with the backend reason", async () => { + const original = apiClient.request; + apiClient.request = (async () => ({ data: { code: 40001, data: null, msg: "身份验证失败", reason: "bad_auth_request" }, status: 400 })) as typeof apiClient.request; + try { + const error = await changeProfilePassword({ currentPassword: "wrong", newPassword: "new-password" }).catch((failure) => failure); + expect(error).toBeInstanceOf(ApiError); + expect(error.reason).toBe("bad_auth_request"); + } finally { + apiClient.request = original; + } + }); +}); diff --git a/web/test/user-avatar.test.tsx b/web/test/user-avatar.test.tsx new file mode 100644 index 000000000..6bb0079ee --- /dev/null +++ b/web/test/user-avatar.test.tsx @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { UserAvatar } from "../src/components/layout/user-avatar"; +import { resolveUserAvatarURL } from "../src/lib/user-avatar-url"; +import { DEFAULT_USER_AVATARS, isDefaultUserAvatarID } from "../src/lib/default-user-avatars"; +import { apiBaseURL } from "../src/services/api/request"; +import type { LocalUser } from "../src/stores/use-user-store"; + +const user: LocalUser = { id: "user-1", username: "user", displayName: "User", role: "user", status: "active" }; + +function renderAvatar(avatarUrl?: string) { + return renderToStaticMarkup(); +} + +describe("shared user avatar image sources", () => { + test("renders authenticated profile images and linked HTTP avatars", () => { + for (const source of ["/api/auth/profile/avatar/user-1?v=012abc", "https://example.com/avatar.png", "http://example.com/avatar.jpg"]) { + const markup = renderAvatar(source); + expect(markup).toContain(` { + for (const source of [ + undefined, + "", + "/assets/avatar.png", + "/api/auth/profile/avatar/../settings", + "/api/auth/profile/avatar/user-1/../../settings", + "/api/auth/profile/avatar/user-1?redirect=https://example.com", + "//example.com/avatar.png", + "data:image/svg+xml,test", + "javascript:alert(1)", + "blob:https://example.com/test", + ]) { + const markup = renderAvatar(source); + expect(markup).not.toContain(" { + const markup = renderToStaticMarkup(); + expect(markup).toContain("app-workspace-account-icon"); + expect(markup).not.toContain(" { + const canonical = "/api/auth/profile/avatar/user-1?v=012abc"; + + test("resolves the canonical path onto API origins and proxy prefixes", () => { + for (const [base, expected] of [ + ["/api", canonical], + ["/api/", canonical], + ["/proxy/yingce/api", "/proxy/yingce/api/auth/profile/avatar/user-1?v=012abc"], + ["/proxy/yingce/api///", "/proxy/yingce/api/auth/profile/avatar/user-1?v=012abc"], + ["https://backend.example.com/api", "https://backend.example.com/api/auth/profile/avatar/user-1?v=012abc"], + ["https://backend.example.com/proxy/yingce/", "https://backend.example.com/proxy/yingce/auth/profile/avatar/user-1?v=012abc"], + ["http://localhost:8080", "http://localhost:8080/auth/profile/avatar/user-1?v=012abc"], + ["/", "/auth/profile/avatar/user-1?v=012abc"], + ]) { + expect(resolveUserAvatarURL(canonical, base)).toBe(expected); + } + }); + + test("keeps third-party URLs independent of the configured API base", () => { + for (const source of ["https://thirdparty.example.com/avatar.png?q=123", "http://thirdparty.example.com/avatar.jpg"]) { + expect(resolveUserAvatarURL(source, "https://backend.example.com/proxy/api")).toBe(source); + } + }); + + test("rejects noncanonical paths before joining with a custom backend", () => { + for (const source of [ + undefined, + "", + "/api/auth/profile/avatar/user-1", + "/api/auth/profile/avatar/user-1?v=", + "/api/auth/profile/avatar/user-1?v=012abc&next=/settings", + "/api/auth/profile/avatar/%2e%2e?v=012abc", + "/api/auth/profile/avatar/user-1/../other?v=012abc", + "/api/auth/profile/avatar/user-1?v=012abc#fragment", + "/proxy/api/auth/profile/avatar/user-1?v=012abc", + "//thirdparty.example.com/avatar.png", + "data:image/png;base64,AAAA", + ]) { + expect(resolveUserAvatarURL(source, "https://backend.example.com/proxy/api")).toBe(""); + } + }); +}); + +describe("system avatar catalog and shared rendering", () => { + test("exposes eight stable distinct presets and validates exact IDs", () => { + expect(DEFAULT_USER_AVATARS.map((avatar) => avatar.id)).toEqual(["person", "director", "camera", "cat", "rabbit", "robot", "rocket", "sparkles"]); + expect(new Set(DEFAULT_USER_AVATARS.map((avatar) => avatar.id)).size).toBe(8); + for (const avatar of DEFAULT_USER_AVATARS) { + expect(isDefaultUserAvatarID(avatar.id)).toBe(true); + expect(avatar.label).not.toBe(""); + } + for (const invalid of [undefined, null, "", "Cat", " cat", "cat ", "default", "../person"]) { + expect(isDefaultUserAvatarID(invalid)).toBe(false); + } + }); + + test("renders the selected icon globally and overrides inherited image URLs", () => { + const iconClasses = ["lucide-circle-user-round", "lucide-clapperboard", "lucide-camera", "lucide-cat", "lucide-rabbit", "lucide-bot", "lucide-rocket", "lucide-sparkles"]; + for (const [index, preset] of DEFAULT_USER_AVATARS.entries()) { + const markup = renderToStaticMarkup( + , + ); + expect(markup).not.toContain(" { + const markup = renderToStaticMarkup(); + expect(markup).toContain("); + expect(legacyDefault).toContain("lucide-circle-user-round"); + }); +});