- 密码哈希从 MD5+SHA1 升级为 bcrypt - 删除 super_admins/users 表中的 salt 字段 - 删除旧版升级文件(upgrade.php, check_upgrade, execute_upgrade, sql/upgrades/) - 删除 PASSWORD_SALT 配置项 - 清理所有'兼容 Python 版'注释 - 新项目独立,无历史包袱
460 lines
14 KiB
Go
460 lines
14 KiB
Go
// ===========================================
|
||
// 多班级版班级管理系统 - Go 后端
|
||
//
|
||
// 开发者: Canglan
|
||
// 联系方式: admin@sea-studio.top
|
||
// 版权归属: Sea Network Technology Studio
|
||
// 许可证: Apache License 2.0
|
||
//
|
||
// 版权所有 © Sea Network Technology Studio
|
||
// ===========================================
|
||
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
|
||
"github.com/redis/go-redis/v9"
|
||
|
||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
|
||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/crypto"
|
||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database"
|
||
appJwt "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/jwt"
|
||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger"
|
||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config"
|
||
)
|
||
|
||
// AuthService 认证服务
|
||
type AuthService struct {
|
||
userRepo *repository.UserRepo
|
||
studentRepo *repository.StudentRepo
|
||
adminRoleRepo *repository.AdminRoleRepo
|
||
classRepo *repository.ClassRepo
|
||
logService *LogService
|
||
}
|
||
|
||
// NewAuthService 创建认证服务
|
||
func NewAuthService(
|
||
userRepo *repository.UserRepo,
|
||
studentRepo *repository.StudentRepo,
|
||
adminRoleRepo *repository.AdminRoleRepo,
|
||
classRepo *repository.ClassRepo,
|
||
logService *LogService,
|
||
) *AuthService {
|
||
return &AuthService{
|
||
userRepo: userRepo,
|
||
studentRepo: studentRepo,
|
||
adminRoleRepo: adminRoleRepo,
|
||
classRepo: classRepo,
|
||
logService: logService,
|
||
}
|
||
}
|
||
|
||
// LoginResult 登录结果
|
||
type LoginResult struct {
|
||
Success bool `json:"success"`
|
||
Message string `json:"message,omitempty"`
|
||
Token string `json:"token,omitempty"`
|
||
UserID int `json:"user_id,omitempty"`
|
||
Username string `json:"username,omitempty"`
|
||
RealName string `json:"real_name,omitempty"`
|
||
UserType string `json:"user_type,omitempty"`
|
||
StudentID *int `json:"student_id,omitempty"`
|
||
Role *string `json:"role,omitempty"`
|
||
ClassID *int `json:"class_id,omitempty"`
|
||
ClassName *string `json:"class_name,omitempty"`
|
||
NeedChangePassword bool `json:"need_change_password,omitempty"`
|
||
Redirect string `json:"redirect,omitempty"`
|
||
}
|
||
|
||
// incrWithExpireAtomic 原子递增并在首次设置过期时间(Lua 脚本保证原子性)
|
||
func incrWithExpireAtomic(ctx context.Context, key string, ttlSeconds int) (int64, error) {
|
||
script := redis.NewScript(`
|
||
local current = redis.call('INCR', KEYS[1])
|
||
if current == 1 then
|
||
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||
end
|
||
return current
|
||
`)
|
||
result, err := script.Run(ctx, database.RDB, []string{key}, ttlSeconds).Int64()
|
||
return result, err
|
||
}
|
||
|
||
// Login 用户登录
|
||
func (s *AuthService) Login(username, password, ip, userAgent string) *LoginResult {
|
||
ctx := context.Background()
|
||
cfg := config.AppConfig
|
||
|
||
// 检查登录失败次数(用户名级 + IP 级双重限流,使用原子 Incr 防止 TOCTOU 竞态)
|
||
attemptsKey := fmt.Sprintf("login_attempts:%s", username)
|
||
ipAttemptsKey := fmt.Sprintf("login_attempts:ip:%s", ip)
|
||
|
||
// 用户名级限流:原子递增后检查
|
||
userCount, err := incrWithExpireAtomic(ctx, attemptsKey, 300)
|
||
if err != nil {
|
||
logger.Sugared.Errorf("Redis 限流检查失败 (用户名级): %v", err)
|
||
return &LoginResult{Success: false, Message: "系统繁忙,请稍后重试"}
|
||
}
|
||
if userCount > 5 {
|
||
s.logService.WriteLoginLog(username, 0, ip, userAgent, "登录失败次数过多")
|
||
return &LoginResult{Success: false, Message: "登录失败次数过多,请5分钟后重试"}
|
||
}
|
||
// IP 级限流:原子递增后检查
|
||
ipCount, err := incrWithExpireAtomic(ctx, ipAttemptsKey, 300)
|
||
if err != nil {
|
||
logger.Sugared.Errorf("Redis 限流检查失败 (IP级): %v", err)
|
||
return &LoginResult{Success: false, Message: "系统繁忙,请稍后重试"}
|
||
}
|
||
if ipCount > 20 {
|
||
s.logService.WriteLoginLog(username, 0, ip, userAgent, "IP登录失败次数过多")
|
||
return &LoginResult{Success: false, Message: "登录失败次数过多,请5分钟后重试"}
|
||
}
|
||
|
||
// 获取用户
|
||
user, err := s.userRepo.GetByUsername(username)
|
||
if err != nil {
|
||
// 尝试学生登录:username 匹配 student_no
|
||
student, stuErr := s.studentRepo.GetByStudentNo(username, 0)
|
||
if stuErr == nil && student != nil {
|
||
return s.loginAsStudent(student, password, ip, userAgent, cfg, attemptsKey, ipAttemptsKey)
|
||
}
|
||
// 尝试家长登录:username 匹配 parent_account
|
||
return s.tryParentLogin(username, password, ip, userAgent, cfg, attemptsKey, ipAttemptsKey)
|
||
}
|
||
|
||
// 验证密码(bcrypt)
|
||
if !crypto.VerifyPassword(password, user.PasswordHash) {
|
||
s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误")
|
||
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
||
}
|
||
|
||
// 检查账号状态
|
||
if user.Status != 1 {
|
||
s.logService.WriteLoginLog(username, 0, ip, userAgent, "账号已被禁用")
|
||
return &LoginResult{Success: false, Message: "账号已被禁用"}
|
||
}
|
||
|
||
// 清除用户名级登录失败记录,IP 级计数由 TTL 自然过期(防止同 IP 其他用户限流被重置)
|
||
database.RDB.Del(ctx, attemptsKey)
|
||
|
||
// 更新最后登录信息
|
||
_ = s.userRepo.UpdateLastLogin(user.UserID, ip)
|
||
|
||
// 获取角色和班级信息
|
||
var role *string
|
||
var classID *int
|
||
var className *string
|
||
|
||
if user.UserType == "admin" {
|
||
adminRole, err := s.adminRoleRepo.GetByUserID(user.UserID)
|
||
if err == nil && adminRole != nil {
|
||
role = &adminRole.RoleType
|
||
classID = &adminRole.ClassID
|
||
}
|
||
} else if user.UserType == "super_admin" {
|
||
r := "系统管理员"
|
||
role = &r
|
||
} else if user.StudentID != nil {
|
||
student, err := s.studentRepo.GetByID(*user.StudentID)
|
||
if err == nil && student != nil {
|
||
cid := student.ClassID
|
||
classID = &cid
|
||
}
|
||
}
|
||
|
||
// 获取班级名称
|
||
if classID != nil {
|
||
cls, err := s.classRepo.GetByID(*classID)
|
||
if err == nil && cls != nil {
|
||
className = &cls.ClassName
|
||
}
|
||
}
|
||
|
||
// 生成 Token
|
||
token, err := appJwt.CreateToken(
|
||
user.UserID, user.Username, user.UserType,
|
||
user.StudentID, derefStr(role), user.RealName, classID,
|
||
user.NeedChangePassword == 1,
|
||
)
|
||
if err != nil {
|
||
return &LoginResult{Success: false, Message: "生成令牌失败"}
|
||
}
|
||
|
||
// 存储 Token 到 Redis(使用 IdleTimeout 与中间件空闲超时一致,避免 Token 在 Redis 中残留过久)
|
||
_ = database.SetUserToken(ctx, user.UserID, token, cfg.JWTIdleTimeoutMinutes)
|
||
// 确定跳转路径
|
||
redirect := getRedirectPath(user.UserType, role)
|
||
|
||
// 需要强制改密时,跳转到密码修改页面
|
||
needChangePassword := user.NeedChangePassword == 1
|
||
if needChangePassword {
|
||
redirect = getPasswordChangePath(user.UserType)
|
||
}
|
||
|
||
s.logService.WriteLoginLog(username, 1, ip, userAgent, "")
|
||
|
||
return &LoginResult{
|
||
Success: true,
|
||
Token: token,
|
||
UserID: user.UserID,
|
||
Username: user.Username,
|
||
RealName: user.RealName,
|
||
UserType: user.UserType,
|
||
StudentID: user.StudentID,
|
||
Role: role,
|
||
ClassID: classID,
|
||
ClassName: className,
|
||
NeedChangePassword: needChangePassword,
|
||
Redirect: redirect,
|
||
}
|
||
}
|
||
|
||
// loginAsStudent 学生登录(通过学号)
|
||
func (s *AuthService) loginAsStudent(student *model.Student, password, ip, userAgent string, cfg *config.Config, attemptsKey, ipAttemptsKey string) *LoginResult {
|
||
ctx := context.Background()
|
||
|
||
user, err := s.userRepo.GetByUsername(student.StudentNo)
|
||
if err != nil {
|
||
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
||
}
|
||
|
||
if !crypto.VerifyPassword(password, user.PasswordHash) {
|
||
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
||
}
|
||
|
||
if user.Status != 1 {
|
||
return &LoginResult{Success: false, Message: "账号已被禁用"}
|
||
}
|
||
|
||
// 清除用户名级登录失败记录
|
||
database.RDB.Del(ctx, attemptsKey)
|
||
_ = s.userRepo.UpdateLastLogin(user.UserID, ip)
|
||
|
||
classID := student.ClassID
|
||
var className *string
|
||
cls, err := s.classRepo.GetByID(classID)
|
||
if err == nil && cls != nil {
|
||
className = &cls.ClassName
|
||
}
|
||
|
||
token, err := appJwt.CreateToken(user.UserID, user.Username, user.UserType, user.StudentID, "", user.RealName, &classID, user.NeedChangePassword == 1)
|
||
if err != nil {
|
||
return &LoginResult{Success: false, Message: "生成令牌失败"}
|
||
}
|
||
|
||
_ = database.SetUserToken(ctx, user.UserID, token, cfg.JWTIdleTimeoutMinutes)
|
||
s.logService.WriteLoginLog(user.Username, 1, ip, userAgent, "")
|
||
|
||
needChangePassword := user.NeedChangePassword == 1
|
||
redirect := "/student/dashboard.php"
|
||
if needChangePassword {
|
||
redirect = "/student/password.php"
|
||
}
|
||
|
||
return &LoginResult{
|
||
Success: true,
|
||
Token: token,
|
||
UserID: user.UserID,
|
||
Username: user.Username,
|
||
RealName: user.RealName,
|
||
UserType: user.UserType,
|
||
StudentID: user.StudentID,
|
||
ClassID: &classID,
|
||
ClassName: className,
|
||
NeedChangePassword: needChangePassword,
|
||
Redirect: redirect,
|
||
}
|
||
}
|
||
|
||
// tryParentLogin 尝试家长登录(通过 parent_account 查找学生,再获取关联的家长用户)
|
||
func (s *AuthService) tryParentLogin(username, password, ip, userAgent string, cfg *config.Config, attemptsKey, ipAttemptsKey string) *LoginResult {
|
||
ctx := context.Background()
|
||
|
||
// 根据 parent_account 字段查找学生
|
||
student, err := s.studentRepo.GetByParentAccount(username)
|
||
if err != nil || student == nil {
|
||
s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误")
|
||
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
||
}
|
||
|
||
// 根据学生ID获取关联的家长用户账号
|
||
user, err := s.userRepo.GetByStudentID(student.StudentID)
|
||
if err != nil || user == nil || user.UserType != "parent" {
|
||
s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误")
|
||
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
||
}
|
||
|
||
if !crypto.VerifyPassword(password, user.PasswordHash) {
|
||
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
||
}
|
||
|
||
// 清除用户名级登录失败记录
|
||
database.RDB.Del(ctx, attemptsKey)
|
||
_ = s.userRepo.UpdateLastLogin(user.UserID, ip)
|
||
|
||
classID := student.ClassID
|
||
var className *string
|
||
cls, err := s.classRepo.GetByID(classID)
|
||
if err == nil && cls != nil {
|
||
className = &cls.ClassName
|
||
}
|
||
|
||
token, err := appJwt.CreateToken(user.UserID, user.Username, user.UserType, user.StudentID, "", user.RealName, &classID, user.NeedChangePassword == 1)
|
||
if err != nil {
|
||
return &LoginResult{Success: false, Message: "生成令牌失败"}
|
||
}
|
||
|
||
_ = database.SetUserToken(ctx, user.UserID, token, cfg.JWTIdleTimeoutMinutes)
|
||
s.logService.WriteLoginLog(username, 1, ip, userAgent, "")
|
||
|
||
needChangePassword := user.NeedChangePassword == 1
|
||
redirect := "/parent/dashboard.php"
|
||
if needChangePassword {
|
||
redirect = "/parent/password.php"
|
||
}
|
||
|
||
return &LoginResult{
|
||
Success: true,
|
||
Token: token,
|
||
UserID: user.UserID,
|
||
Username: user.Username,
|
||
RealName: user.RealName,
|
||
UserType: user.UserType,
|
||
StudentID: user.StudentID,
|
||
ClassID: &classID,
|
||
ClassName: className,
|
||
NeedChangePassword: needChangePassword,
|
||
Redirect: redirect,
|
||
}
|
||
}
|
||
|
||
// Logout 用户登出
|
||
func (s *AuthService) Logout(userID int) error {
|
||
ctx := context.Background()
|
||
return database.DeleteUserToken(ctx, userID)
|
||
}
|
||
|
||
// ChangePassword 修改密码
|
||
func (s *AuthService) ChangePassword(userID int, oldPassword, newPassword string, force bool) error {
|
||
user, err := s.userRepo.GetByUserID(userID)
|
||
if err != nil {
|
||
return fmt.Errorf("用户不存在")
|
||
}
|
||
|
||
// 验证原密码(强制改密时跳过)
|
||
if !force {
|
||
if !crypto.VerifyPassword(oldPassword, user.PasswordHash) {
|
||
return fmt.Errorf("原密码错误")
|
||
}
|
||
}
|
||
|
||
// 验证新密码强度
|
||
if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid {
|
||
return fmt.Errorf("%s", msg)
|
||
}
|
||
|
||
// 更新密码
|
||
newHash, err := crypto.HashPassword(newPassword)
|
||
if err != nil {
|
||
return fmt.Errorf("密码加密失败")
|
||
}
|
||
if err := s.userRepo.UpdatePassword(userID, newHash); err != nil {
|
||
return fmt.Errorf("密码修改失败")
|
||
}
|
||
|
||
// 清除 Token
|
||
ctx := context.Background()
|
||
_ = database.DeleteUserToken(ctx, userID)
|
||
return nil
|
||
}
|
||
|
||
// GetUserInfo 获取用户信息
|
||
func (s *AuthService) GetUserInfo(userID int) (map[string]interface{}, error) {
|
||
user, err := s.userRepo.GetByUserID(userID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("用户不存在")
|
||
}
|
||
|
||
result := map[string]interface{}{
|
||
"user_id": user.UserID,
|
||
"username": user.Username,
|
||
"real_name": user.RealName,
|
||
"user_type": user.UserType,
|
||
"need_change_password": user.NeedChangePassword == 1,
|
||
}
|
||
|
||
var classID int
|
||
|
||
if user.StudentID != nil {
|
||
student, err := s.studentRepo.GetByID(*user.StudentID)
|
||
if err == nil && student != nil {
|
||
result["student_no"] = student.StudentNo
|
||
result["student_name"] = student.Name
|
||
result["total_points"] = student.TotalPoints
|
||
classID = student.ClassID
|
||
}
|
||
}
|
||
|
||
if user.UserType == "admin" {
|
||
adminRole, err := s.adminRoleRepo.GetByUserID(userID)
|
||
if err == nil && adminRole != nil {
|
||
result["role"] = adminRole.RoleType
|
||
classID = adminRole.ClassID
|
||
}
|
||
}
|
||
|
||
if classID > 0 {
|
||
result["class_id"] = classID
|
||
cls, err := s.classRepo.GetByID(classID)
|
||
if err == nil && cls != nil {
|
||
result["class_name"] = cls.ClassName
|
||
}
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// UnlockAccount 解锁账号(清除用户名级 + IP 级登录失败计数)
|
||
func (s *AuthService) UnlockAccount(username, ip string) error {
|
||
ctx := context.Background()
|
||
keys := []string{fmt.Sprintf("login_attempts:%s", username)}
|
||
if ip != "" {
|
||
keys = append(keys, fmt.Sprintf("login_attempts:ip:%s", ip))
|
||
}
|
||
return database.RDB.Del(ctx, keys...).Err()
|
||
}
|
||
|
||
// getRedirectPath 根据用户类型和角色确定跳转路径
|
||
func getRedirectPath(userType string, role *string) string {
|
||
switch userType {
|
||
case "super_admin":
|
||
return "/admin/dashboard.php"
|
||
case "admin":
|
||
return "/admin/dashboard.php"
|
||
case "student":
|
||
return "/student/dashboard.php"
|
||
case "parent":
|
||
return "/parent/dashboard.php"
|
||
default:
|
||
return "/"
|
||
}
|
||
}
|
||
|
||
// getPasswordChangePath 根据用户类型返回密码修改页面路径
|
||
func getPasswordChangePath(userType string) string {
|
||
switch userType {
|
||
case "super_admin":
|
||
return "/admin/password.php"
|
||
case "admin":
|
||
return "/admin/password.php"
|
||
case "student":
|
||
return "/student/password.php"
|
||
case "parent":
|
||
return "/parent/password.php"
|
||
default:
|
||
return "/"
|
||
}
|
||
}
|