254 lines
8.9 KiB
Go
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
|
|
}
|