feat(auth): 用户账户 - 完善个人资料与登录日志 (#752)

Co-authored-by: wuligao <1247662379@qq.com>
This commit is contained in:
ddcat666
2026-10-13 05:31:30 +08:00
committed by GitHub
parent e70fe1b9c9
commit 4c1d2b4012
83 changed files with 5836 additions and 328 deletions

View File

@@ -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
}

View File

@@ -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")
}
})
}
}

View File

@@ -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
}

View File

@@ -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")
}
})

View File

@@ -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
}

View File

@@ -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(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`)},
{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)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}
})
}
}
}

View File

@@ -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)
}

View File

@@ -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"}})

View File

@@ -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 {