Files
ttstd-infinite-canvas/backend/internal/auth/account.go
2026-10-13 05:31:30 +08:00

254 lines
8.9 KiB
Go

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
}