You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
343 lines
12 KiB
343 lines
12 KiB
package service
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
"gorm.io/gorm"
|
|
|
|
"laic-backend/cache"
|
|
"laic-backend/common"
|
|
"laic-backend/logger"
|
|
"laic-backend/model"
|
|
"laic-backend/token"
|
|
"laic-backend/tool"
|
|
"laic-backend/vo"
|
|
)
|
|
|
|
type UserService struct{}
|
|
|
|
var DefaultUserService = &UserService{}
|
|
|
|
func (s *UserService) Register(name, phone, email, password, smsCode, userAgent, ip string) (*vo.LoginResp, *common.BusiError) {
|
|
if !s.consumeSmsCode(phone, smsCode) {
|
|
return nil, common.ErrSmsCodeError
|
|
}
|
|
var count int64
|
|
if err := common.DB.Model(&model.User{}).Where("phone = ?", phone).Count(&count).Error; err != nil {
|
|
return nil, common.ErrInternal
|
|
}
|
|
if count > 0 {
|
|
return nil, common.ErrUserPhoneExists
|
|
}
|
|
id, err := tool.NextID()
|
|
if err != nil {
|
|
return nil, common.ErrInternal
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, common.ErrInternal
|
|
}
|
|
now := time.Now()
|
|
user := &model.User{ID: id, Name: name, Phone: phone, Email: email, Password: string(hash), Role: common.RoleUser, Status: 1, CreatedAt: now, UpdatedAt: now}
|
|
if err := common.DB.Create(user).Error; err != nil {
|
|
if code, _ := common.ParseError(err); code == 1062 {
|
|
return nil, common.ErrUserPhoneExists
|
|
}
|
|
logger.ERROR("注册用户失败", err)
|
|
return nil, common.ErrInternal
|
|
}
|
|
return s.buildLoginResp(user, userAgent, ip)
|
|
}
|
|
|
|
func (s *UserService) Login(phone, password, captchaValue, captchaID, smsCode, userAgent, ip string) (*vo.LoginResp, *common.BusiError) {
|
|
passwordLogin := password != ""
|
|
if passwordLogin {
|
|
if !CheckCaptcha(captchaID, captchaValue) {
|
|
return nil, common.NewBusiError(common.ParamError, "图形验证码错误或已过期")
|
|
}
|
|
} else if !s.consumeSmsCode(phone, smsCode) {
|
|
return nil, common.ErrSmsCodeError
|
|
}
|
|
var user model.User
|
|
if err := common.DB.Where("phone = ? AND status = 1", phone).First(&user).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, common.ErrPasswordError
|
|
}
|
|
logger.ERROR("登录查询用户失败", err)
|
|
return nil, common.ErrInternal
|
|
}
|
|
if passwordLogin && !checkPassword(password, user.Password) {
|
|
return nil, common.ErrPasswordError
|
|
}
|
|
_ = common.DB.Model(&user).Update("last_login", time.Now()).Error
|
|
return s.buildLoginResp(&user, userAgent, ip)
|
|
}
|
|
|
|
func (s *UserService) Refresh(refreshToken, userAgent, ip string) (*vo.LoginResp, *common.BusiError) {
|
|
userID, sessionID, err := token.ParseRefreshToken(refreshToken)
|
|
if err != nil {
|
|
return nil, common.ErrTokenInvalid
|
|
}
|
|
refreshID := token.GenerateShortID(refreshToken)
|
|
refreshKey := cache.RefreshTokenKeyOf(userID, refreshID)
|
|
stored, err := common.GetString(refreshKey)
|
|
if err != nil || stored != sessionValue(sessionID) {
|
|
return nil, common.ErrTokenInvalid
|
|
}
|
|
var session model.UserLoginSession
|
|
if err := common.DB.Where("id = ? AND user_id = ? AND refresh_token_id = ? AND revoked_at IS NULL AND expires_at > ?", sessionID, userID, refreshID, time.Now()).First(&session).Error; err != nil {
|
|
return nil, common.ErrTokenInvalid
|
|
}
|
|
var user model.User
|
|
if err := common.DB.Where("id = ? AND status = 1", userID).First(&user).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, common.ErrUserNotFound
|
|
}
|
|
return nil, common.ErrInternal
|
|
}
|
|
_ = common.Delete(refreshKey)
|
|
_ = common.Delete(cache.UserTokenKeyOf(userID, session.AccessTokenID))
|
|
return s.issueForSession(&user, &session, userAgent, ip)
|
|
}
|
|
|
|
func (s *UserService) Logout(userID, sessionID int64, accessToken, refreshToken string) *common.BusiError {
|
|
if sessionID == 0 {
|
|
_ = common.Delete(cache.UserTokenKeyOf(userID, token.GenerateShortID(accessToken)))
|
|
if refreshToken != "" {
|
|
_ = common.Delete(cache.RefreshTokenKeyOf(userID, token.GenerateShortID(refreshToken)))
|
|
}
|
|
return nil
|
|
}
|
|
return s.revokeSession(userID, sessionID)
|
|
}
|
|
|
|
func (s *UserService) ChangePassword(userID int64, req *vo.ChangePasswordReq) *common.BusiError {
|
|
var user model.User
|
|
if err := common.DB.First(&user, userID).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return common.ErrUserNotFound
|
|
}
|
|
return common.ErrInternal
|
|
}
|
|
if !checkPassword(req.OldPassword, user.Password) {
|
|
return common.ErrPasswordError
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return common.ErrInternal
|
|
}
|
|
now := time.Now()
|
|
if err := common.DB.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Model(&user).Updates(map[string]any{"password": string(hash), "password_changed_at": now, "updated_at": now}).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Model(&model.UserLoginSession{}).Where("user_id = ? AND revoked_at IS NULL", userID).Update("revoked_at", now).Error
|
|
}).Error; err != nil {
|
|
return common.ErrInternal
|
|
}
|
|
s.deleteUserSessions(userID)
|
|
return nil
|
|
}
|
|
|
|
func (s *UserService) ListSessions(userID, currentSessionID int64) ([]vo.SessionVO, *common.BusiError) {
|
|
var sessions []model.UserLoginSession
|
|
if err := common.DB.Where("user_id = ? AND revoked_at IS NULL", userID).Order("last_active_at DESC").Find(&sessions).Error; err != nil {
|
|
return nil, common.ErrInternal
|
|
}
|
|
result := make([]vo.SessionVO, 0, len(sessions))
|
|
for _, item := range sessions {
|
|
result = append(result, vo.SessionVO{ID: item.ID, UserAgent: item.UserAgent, IP: item.IP, LastActiveAt: item.LastActiveAt, ExpiresAt: item.ExpiresAt, Current: item.ID == currentSessionID})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *UserService) RevokeSession(userID, sessionID int64) *common.BusiError {
|
|
return s.revokeSession(userID, sessionID)
|
|
}
|
|
|
|
func (s *UserService) RevokeOtherSessions(userID, currentSessionID int64) *common.BusiError {
|
|
var sessions []model.UserLoginSession
|
|
if err := common.DB.Where("user_id = ? AND id <> ? AND revoked_at IS NULL", userID, currentSessionID).Find(&sessions).Error; err != nil {
|
|
return common.ErrInternal
|
|
}
|
|
now := time.Now()
|
|
if err := common.DB.Model(&model.UserLoginSession{}).Where("user_id = ? AND id <> ? AND revoked_at IS NULL", userID, currentSessionID).Update("revoked_at", now).Error; err != nil {
|
|
return common.ErrInternal
|
|
}
|
|
for _, session := range sessions {
|
|
s.deleteSessionTokens(userID, &session)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *UserService) TouchSession(userID, sessionID int64) {
|
|
if sessionID == 0 {
|
|
return
|
|
}
|
|
_ = common.DB.Model(&model.UserLoginSession{}).Where("id = ? AND user_id = ? AND revoked_at IS NULL AND last_active_at < ?", sessionID, userID, time.Now().Add(-5*time.Minute)).Update("last_active_at", time.Now()).Error
|
|
}
|
|
|
|
func (s *UserService) SessionValid(userID, sessionID int64, accessToken string) bool {
|
|
if sessionID == 0 {
|
|
return false
|
|
}
|
|
var count int64
|
|
err := common.DB.Model(&model.UserLoginSession{}).Where("id = ? AND user_id = ? AND access_token_id = ? AND revoked_at IS NULL AND expires_at > ?", sessionID, userID, token.GenerateShortID(accessToken), time.Now()).Count(&count).Error
|
|
return err == nil && count == 1
|
|
}
|
|
|
|
func (s *UserService) SendSmsCode(phone, scene string) (string, *common.BusiError) {
|
|
var count int64
|
|
if err := common.DB.Model(&model.User{}).Where("phone = ?", phone).Count(&count).Error; err != nil {
|
|
return "", common.ErrInternal
|
|
}
|
|
if (scene == "login" || scene == "reset") && count == 0 {
|
|
return "", common.ErrUserNotFound
|
|
}
|
|
if scene == "register" && count > 0 {
|
|
return "", common.ErrUserPhoneExists
|
|
}
|
|
if common.HasKey(cache.SmsRateKeyOf(phone)) {
|
|
return "", common.NewBusiError(common.ParamError, "验证码发送过于频繁,请稍后再试")
|
|
}
|
|
code := tool.RandDigit(6)
|
|
if err := common.SetValueWithExpired(cache.SmsCodeKeyOf(phone), code, 5*60); err != nil {
|
|
return "", common.ErrInternal
|
|
}
|
|
if err := common.SetValueWithExpired(cache.SmsRateKeyOf(phone), "1", 60); err != nil {
|
|
return "", common.ErrInternal
|
|
}
|
|
logger.INFO("send sms code to", phone, "code:", code)
|
|
return code, nil
|
|
}
|
|
|
|
func (s *UserService) consumeSmsCode(phone, code string) bool {
|
|
if code == "" {
|
|
return false
|
|
}
|
|
key := cache.SmsCodeKeyOf(phone)
|
|
stored, err := common.GetString(key)
|
|
if err != nil || stored != code {
|
|
return false
|
|
}
|
|
_ = common.Delete(key)
|
|
return true
|
|
}
|
|
|
|
func (s *UserService) ResetPassword(phone, smsCode, newPassword string) *common.BusiError {
|
|
key := cache.SmsCodeKeyOf(phone)
|
|
stored, err := common.GetString(key)
|
|
if err != nil || stored == "" || stored != smsCode {
|
|
return common.ErrSmsCodeError
|
|
}
|
|
var user model.User
|
|
if err := common.DB.Where("phone = ?", phone).First(&user).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return common.ErrUserNotFound
|
|
}
|
|
return common.ErrInternal
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return common.ErrInternal
|
|
}
|
|
now := time.Now()
|
|
if err := common.DB.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Model(&user).Updates(map[string]any{"password": string(hash), "password_changed_at": now, "updated_at": now}).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Model(&model.UserLoginSession{}).Where("user_id = ? AND revoked_at IS NULL", user.ID).Update("revoked_at", now).Error
|
|
}).Error; err != nil {
|
|
return common.ErrInternal
|
|
}
|
|
s.deleteUserSessions(user.ID)
|
|
_ = common.Delete(key)
|
|
return nil
|
|
}
|
|
|
|
func (s *UserService) GetByID(id int64) (*model.User, *common.BusiError) {
|
|
var user model.User
|
|
if err := common.DB.First(&user, id).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, common.ErrUserNotFound
|
|
}
|
|
return nil, common.ErrInternal
|
|
}
|
|
return &user, nil
|
|
}
|
|
|
|
func (s *UserService) buildLoginResp(user *model.User, userAgent, ip string) (*vo.LoginResp, *common.BusiError) {
|
|
id, err := tool.NextID()
|
|
if err != nil {
|
|
return nil, common.ErrInternal
|
|
}
|
|
now := time.Now()
|
|
session := &model.UserLoginSession{ID: id, UserID: user.ID, UserAgent: userAgent, IP: ip, LastActiveAt: now, ExpiresAt: now.Add(time.Duration(token.RefreshExpireSeconds()) * time.Second), CreatedAt: now}
|
|
return s.issueForSession(user, session, userAgent, ip)
|
|
}
|
|
|
|
func (s *UserService) issueForSession(user *model.User, session *model.UserLoginSession, userAgent, ip string) (*vo.LoginResp, *common.BusiError) {
|
|
accessToken, refreshToken, err := token.GenerateToken(user.ID, session.ID, user.Phone, []string{user.Role})
|
|
if err != nil {
|
|
return nil, common.ErrInternal
|
|
}
|
|
session.AccessTokenID = token.GenerateShortID(accessToken)
|
|
session.RefreshTokenID = token.GenerateShortID(refreshToken)
|
|
session.UserAgent, session.IP = userAgent, ip
|
|
session.LastActiveAt = time.Now()
|
|
session.ExpiresAt = time.Now().Add(time.Duration(token.RefreshExpireSeconds()) * time.Second)
|
|
if session.CreatedAt.IsZero() {
|
|
session.CreatedAt = time.Now()
|
|
}
|
|
if err := common.DB.Save(session).Error; err != nil {
|
|
logger.ERROR("保存登录会话失败", err)
|
|
return nil, common.ErrInternal
|
|
}
|
|
if err := common.SetValueWithExpired(cache.UserTokenKeyOf(user.ID, session.AccessTokenID), sessionValue(session.ID), token.AccessExpireSeconds()); err != nil {
|
|
return nil, common.ErrInternal
|
|
}
|
|
if err := common.SetValueWithExpired(cache.RefreshTokenKeyOf(user.ID, session.RefreshTokenID), sessionValue(session.ID), token.RefreshExpireSeconds()); err != nil {
|
|
return nil, common.ErrInternal
|
|
}
|
|
return &vo.LoginResp{AccessToken: accessToken, RefreshToken: refreshToken, User: vo.NewUserVO(user)}, nil
|
|
}
|
|
|
|
func (s *UserService) revokeSession(userID, sessionID int64) *common.BusiError {
|
|
var session model.UserLoginSession
|
|
if err := common.DB.Where("id = ? AND user_id = ?", sessionID, userID).First(&session).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return common.ErrNotFound
|
|
}
|
|
return common.ErrInternal
|
|
}
|
|
now := time.Now()
|
|
if session.RevokedAt == nil {
|
|
_ = common.DB.Model(&session).Update("revoked_at", now).Error
|
|
}
|
|
s.deleteSessionTokens(userID, &session)
|
|
return nil
|
|
}
|
|
|
|
func (s *UserService) deleteUserSessions(userID int64) {
|
|
var sessions []model.UserLoginSession
|
|
if common.DB.Where("user_id = ?", userID).Find(&sessions).Error != nil {
|
|
return
|
|
}
|
|
for _, session := range sessions {
|
|
s.deleteSessionTokens(userID, &session)
|
|
}
|
|
}
|
|
|
|
func (s *UserService) deleteSessionTokens(userID int64, session *model.UserLoginSession) {
|
|
_ = common.Delete(cache.UserTokenKeyOf(userID, session.AccessTokenID))
|
|
_ = common.Delete(cache.RefreshTokenKeyOf(userID, session.RefreshTokenID))
|
|
}
|
|
|
|
func sessionValue(sessionID int64) string { return stringID(sessionID) }
|
|
func stringID(id int64) string { return fmt.Sprintf("%d", id) }
|
|
func checkPassword(password, hash string) bool {
|
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
|
}
|
|
|