feat(auth): 用户账户 - 完善个人资料与登录日志 (#752)
Co-authored-by: wuligao <1247662379@qq.com>
This commit is contained in:
253
backend/internal/auth/account.go
Normal file
253
backend/internal/auth/account.go
Normal 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
|
||||
}
|
||||
231
backend/internal/auth/account_test.go
Normal file
231
backend/internal/auth/account_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
|
||||
191
backend/internal/auth/avatar.go
Normal file
191
backend/internal/auth/avatar.go
Normal 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
|
||||
}
|
||||
371
backend/internal/auth/avatar_test.go
Normal file
371
backend/internal/auth/avatar_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
96
backend/internal/auth/login_logs.go
Normal file
96
backend/internal/auth/login_logs.go
Normal 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
|
||||
}
|
||||
104
backend/internal/auth/login_logs_test.go
Normal file
104
backend/internal/auth/login_logs_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"}})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user