feat(auth): 用户账户 - 完善个人资料与登录日志 (#752)
Co-authored-by: wuligao <1247662379@qq.com>
This commit is contained in:
174
backend/internal/repository/account.go
Normal file
174
backend/internal/repository/account.go
Normal file
@@ -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
|
||||
}
|
||||
243
backend/internal/repository/account_test.go
Normal file
243
backend/internal/repository/account_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
108
backend/internal/repository/login_logs.go
Normal file
108
backend/internal/repository/login_logs.go
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
144
backend/internal/repository/login_logs_test.go
Normal file
144
backend/internal/repository/login_logs_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
53
backend/internal/repository/user_avatar.go
Normal file
53
backend/internal/repository/user_avatar.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user