package logic import ( "context" "encoding/json" "strings" "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "easygoadmin/api/v1" "easygoadmin/internal/consts" "easygoadmin/internal/dao" "easygoadmin/internal/middleware" "easygoadmin/internal/service" "easygoadmin/utility/cache" "easygoadmin/utility/captcha" "easygoadmin/utility/crypto" "easygoadmin/utility/ip2region" jwtutil "easygoadmin/utility/jwt" "easygoadmin/utility/ua" ) // ============================== 认证业务实现 ============================== // sAuth 认证业务实现 // 实现 service.IAuth 接口,承载登录、登出、验证码、令牌等业务逻辑 type sAuth struct{} func init() { service.RegisterAuth(&sAuth{}) // 中间件依赖接线:身份扩展加载 / 租户状态校验 middleware.SetIdentityLoader(LoadIdentity) middleware.SetTenantLoader(LoadTenant) } // Login 登录 // 流程:验证码校验 → 定位用户 → 账号状态与租户校验 → 密码校验 → 签发双令牌 → 写入登录日志 func (s *sAuth) Login(ctx context.Context, req *v1.LoginReq) (res *v1.LoginRes, err error) { start := time.Now() username := strings.TrimSpace(req.Username) // 1. 图形验证码(提供 key 时强制校验) if !captcha.Verify(ctx, req.Key, req.Code) { s.writeLoginLog(ctx, username, consts.LoginLogTypeLogin, 1, "验证码错误或已过期", start, req) return nil, gerror.NewCode(consts.CodeCaptchaError) } // 2. 定位用户(用户名在租户内唯一;未指定租户时全局匹配,优先平台租户) user, err := s.findUser(ctx, username, req.TenantCode) if err != nil { s.writeLoginLog(ctx, username, consts.LoginLogTypeLogin, 1, err.Error(), start, req) return nil, err } // 3. 账号状态 if user.Status != 1 { s.writeLoginLog(ctx, username, consts.LoginLogTypeLogin, 1, "账号已被禁用", start, req) return nil, gerror.NewCode(consts.CodeUserDisabled) } // 4. 租户状态与过期校验 if info := mustLoadTenant(ctx, user.TenantId); info != nil { if info.Status != 1 { s.writeLoginLog(ctx, username, consts.LoginLogTypeLogin, 1, "租户已禁用", start, req) return nil, gerror.NewCode(consts.CodeTenantDisabled) } if info.ExpireTime != nil && info.ExpireTime.Time.Before(time.Now()) { s.writeLoginLog(ctx, username, consts.LoginLogTypeLogin, 1, "租户已过期", start, req) return nil, gerror.NewCode(consts.CodeTenantExpired) } } // 5. 密码校验(外部 salt + bcrypt(password+salt);salt 为空兼容旧数据) if !crypto.IsBcrypt(user.Password) || !crypto.VerifyPassword(user.Password, req.Password, user.Salt) { s.writeLoginLog(ctx, username, consts.LoginLogTypeLogin, 1, "密码错误", start, req) return nil, gerror.NewCode(consts.CodePasswordWrong) } // 6. 签发双令牌 loginCtx := consts.WithUserID(consts.WithUsername(ctx, username), int(user.Id)) loginCtx = consts.WithTenantID(loginCtx, user.TenantId) access, accessClaims, err := jwtutil.Sign(loginCtx, jwtutil.TokenTypeAccess, int(user.Id), user.TenantId, user.Username, user.Realname) if err != nil { return nil, err } refresh, refreshClaims, err := jwtutil.Sign(loginCtx, jwtutil.TokenTypeRefresh, int(user.Id), user.TenantId, user.Username, user.Realname) if err != nil { return nil, err } // 7. 登录日志(成功) s.writeLoginLog(ctx, username, consts.LoginLogTypeLogin, 0, "", start, req) // 在线登记(访问令牌 + 刷新令牌信息) MarkOnline(ctx, accessClaims, refreshClaims, access, refresh, int(user.Id), user.Username, user.Realname, consts.GetClientIP(ctx), consts.GetUserAgent(ctx)) expireSec := int64(jwtutil.ExpireDuration(ctx, jwtutil.TokenTypeAccess).Seconds()) return &v1.LoginRes{ AccessToken: access, RefreshToken: refresh, ExpiresIn: expireSec, TokenType: "Bearer", }, nil } // Logout 登出 // 将当前 access token 的 jti 加入黑名单,并写入登录日志 func (s *sAuth) Logout(ctx context.Context, req *v1.LogoutReq) (res *v1.LogoutRes, err error) { start := time.Now() jti := consts.GetTokenJTI(ctx) if jti != "" { ttl := int(time.Until(consts.GetTokenExpire(ctx)).Seconds()) if ttl > 0 { _ = cache.Set(ctx, consts.CacheKeyTokenBlacklist(jti), "1", ttl) } MarkOffline(ctx, jti) } s.writeLoginLog(ctx, consts.GetUsername(ctx), consts.LoginLogTypeLogout, 0, "", start, nil) return &v1.LogoutRes{}, nil } // Captcha 生成图形验证码 // 返回验证码标识与图片内容 func (s *sAuth) Captcha(ctx context.Context, req *v1.CaptchaReq) (res *v1.CaptchaRes, err error) { key, dataURL, err := captcha.Generate(ctx) if err != nil { return nil, err } return &v1.CaptchaRes{Key: key, Captcha: dataURL}, nil } // Refresh 刷新令牌 // 支持 refresh_token 轮换,并兼容密码模式兑换 func (s *sAuth) Refresh(ctx context.Context, req *v1.RefreshReq) (res *v1.RefreshRes, err error) { // 密码模式(前端 login2 变体):复用 Login if req.GrantType == "password" || req.RefreshToken == "" { loginRes, lerr := s.Login(ctx, &v1.LoginReq{ Username: req.Username, Password: req.Password, Code: req.Code, Key: req.Key, }) if lerr != nil { return nil, lerr } return &v1.RefreshRes{ AccessToken: loginRes.AccessToken, RefreshToken: loginRes.RefreshToken, ExpiresIn: loginRes.ExpiresIn, TokenType: loginRes.TokenType, }, nil } // refresh_token 模式 claims, err := jwtutil.Parse(ctx, req.RefreshToken) if err != nil { return nil, err } if claims.Type != jwtutil.TokenTypeRefresh { return nil, gerror.NewCode(consts.CodeTokenInvalid) } // 旧 refresh 已拉黑则失效 if cache.Exists(ctx, consts.CacheKeyTokenBlacklist(claims.ID)) { return nil, gerror.NewCode(consts.CodeTokenInvalid) } // 轮换:旧 refresh 入黑名单 if claims.ExpiresAt != nil { ttl := int(time.Until(claims.ExpiresAt.Time).Seconds()) if ttl > 0 { _ = cache.Set(ctx, consts.CacheKeyTokenBlacklist(claims.ID), "1", ttl) } } access, accessClaims, err := jwtutil.Sign(ctx, jwtutil.TokenTypeAccess, claims.UserID, claims.TenantID, claims.Username, claims.Realname) if err != nil { return nil, err } // 令牌轮换后刷新在线登记 refresh, refreshClaims, err := jwtutil.Sign(ctx, jwtutil.TokenTypeRefresh, claims.UserID, claims.TenantID, claims.Username, claims.Realname) if err != nil { return nil, err } // 令牌轮换后刷新在线登记 MarkOnline(ctx, accessClaims, refreshClaims, access, refresh, claims.UserID, claims.Username, claims.Realname, consts.GetClientIP(ctx), consts.GetUserAgent(ctx)) return &v1.RefreshRes{ AccessToken: access, RefreshToken: refresh, ExpiresIn: int64(jwtutil.ExpireDuration(ctx, jwtutil.TokenTypeAccess).Seconds()), TokenType: "Bearer", }, nil } // CheckToken 校验令牌有效性 // JWT 中间件已通过即视为有效,返回当前登录用户ID func (s *sAuth) CheckToken(ctx context.Context, req *v1.CheckTokenReq) (res *v1.CheckTokenRes, err error) { return &v1.CheckTokenRes{ Valid: true, UserId: int64(consts.GetUserID(ctx)), }, nil } // RemoveToken 指定令牌撤销(踢下线) // 将指定令牌加入黑名单 func (s *sAuth) RemoveToken(ctx context.Context, req *v1.RemoveTokenReq) (res *v1.RemoveTokenRes, err error) { claims, err := jwtutil.Parse(ctx, req.Token) if err != nil { return nil, err } if claims.ExpiresAt != nil { ttl := int(time.Until(claims.ExpiresAt.Time).Seconds()) if ttl > 0 { _ = cache.Set(ctx, consts.CacheKeyTokenBlacklist(claims.ID), "1", ttl) } } return &v1.RemoveTokenRes{}, nil } // ---------- 定位与校验 ---------- // findUser 按用户名与可选租户编码定位用户 // 未指定租户时全局匹配,多租户同名优先平台租户,仍歧义则提示携带租户编码 func (s *sAuth) findUser(ctx context.Context, username, tenantCode string) (*entityUser, error) { if username == "" { return nil, gerror.NewCode(consts.CodeUserNotFound) } query := dao.User.Ctx(ctx).Where("username", username) tenantID := 0 if tenantCode != "" { tOne, terr := dao.Tenant.Ctx(ctx).Where("code", tenantCode).One() if terr != nil || len(tOne) == 0 { return nil, gerror.NewCode(consts.CodeTenantNotFound) } tenantID = tOne["id"].Int() query = query.Where("tenant_id", tenantID) } list, err := query.All() if err != nil { return nil, err } if len(list) == 0 { return nil, gerror.NewCode(consts.CodeUserNotFound) } // 多租户同名:优先平台租户(tenant_id=0),仍歧义则要求携带租户编码 if len(list) == 1 { return rowToUser(list[0]), nil } for _, row := range list { if row["tenant_id"].Int() == 0 { return rowToUser(row), nil } } return nil, gerror.NewCodef(consts.CodeUserNotFound, "该用户名存在于多个租户,请携带租户编码登录") } // entityUser 用户摘要 // 登录所需的最小字段集合 type entityUser struct { Id int64 // 用户ID TenantId int // 租户ID Username string // 用户名 Password string // 密码 Salt string // 密码盐 Realname string // 真实姓名 Status int // 状态:1-正常 2-禁用 } // rowToUser 数据库记录转换为用户摘要 func rowToUser(row gdb.Record) *entityUser { return &entityUser{ Id: row["id"].Int64(), TenantId: row["tenant_id"].Int(), Username: row["username"].String(), Password: row["password"].String(), Salt: row["salt"].String(), Realname: row["realname"].String(), Status: row["status"].Int(), } } // ---------- 中间件加载器 ---------- // LoadIdentity 加载用户角色与权限 // 由 JWT 中间件调用,结果由中间件缓存 300 秒 func LoadIdentity(ctx context.Context, userID int) (*middleware.Identity, error) { // DataScope 以 0 为"未取到"哨兵,角色循环内取最小(最宽松),结束时回退"全部" idn := &middleware.Identity{Roles: []string{}, Perms: []string{}} if userID <= 0 { return idn, nil } // 所属部门(数据权限过滤用) if one, e := dao.User.Ctx(ctx).Where("id", userID).Fields("dept_id").One(); e == nil && len(one) > 0 { idn.DeptId = one["dept_id"].Int() } // 角色编码 roleRows, err := dao.UserRole.Ctx(ctx).Where("user_id", userID).All() if err != nil || len(roleRows) == 0 { return idn, err } roleIds := make([]int64, 0, len(roleRows)) for _, r := range roleRows { roleIds = append(roleIds, r["role_id"].Int64()) } roleRows2, err := dao.Role.Ctx(ctx).WhereIn("id", roleIds).All() if err != nil { return idn, err } for _, r := range roleRows2 { idn.Roles = append(idn.Roles, r["code"].String()) // 数据范围多角色取最宽松(数值越小越宽;缺列值按"全部"参与比较) sc := r["data_scope"].Int() if sc <= 0 { sc = consts.DataScopeAll } if idn.DataScope == 0 || sc < idn.DataScope { idn.DataScope = sc } } if idn.DataScope == 0 { idn.DataScope = consts.DataScopeAll // 无角色或角色均无该列时回退 } // 权限节点(role → menu.permission) menuRows, err := dao.RoleMenu.Ctx(ctx).WhereIn("role_id", roleIds).All() if err != nil || len(menuRows) == 0 { return idn, err } menuIds := make([]int64, 0, len(menuRows)) for _, r := range menuRows { menuIds = append(menuIds, r["menu_id"].Int64()) } permRows, err := dao.Menu.Ctx(ctx).WhereIn("id", menuIds). Where("type", 1).Where("status", 0).Where("hide", 0).All() // 仅取权限节点(type=1),启用且非隐藏 if err != nil { return idn, err } seen := make(map[string]bool) for _, r := range permRows { p := r["permission"].String() if p != "" && !seen[p] { seen[p] = true idn.Perms = append(idn.Perms, p) } } return idn, nil } // LoadTenant 加载租户状态摘要 // 由 Tenant 中间件调用,缓存 60 秒;租户不存在按禁用处理,实现失效关闭 func LoadTenant(ctx context.Context, tenantID int) (*middleware.TenantInfo, error) { if tenantID == 0 { return &middleware.TenantInfo{ID: 0, Status: 1}, nil } one, err := dao.Tenant.Ctx(ctx).Where("id", tenantID).One() if err != nil { return nil, err // 基础设施错误:中间件放行 } if len(one) == 0 { return &middleware.TenantInfo{ID: tenantID, Status: 2}, nil // 租户不存在 → 禁用 } info := &middleware.TenantInfo{ ID: tenantID, Status: one["status"].Int(), } if v := one["expire_time"]; !v.IsNil() { t := v.GTime() if t != nil { info.ExpireTime = t } } return info, nil } // mustLoadTenant 登录路径上的租户校验 // 带 60 秒缓存,避免登录过程中重复查询租户信息 func mustLoadTenant(ctx context.Context, tenantID int) *middleware.TenantInfo { ck := consts.CacheKeyTenantInfo(tenantID) if v, ok := cache.Get(ctx, ck); ok { info := &middleware.TenantInfo{} if json.Unmarshal([]byte(v), info) == nil { return info } } info, err := LoadTenant(ctx, tenantID) if err != nil || info == nil { return nil } if b, berr := json.Marshal(info); berr == nil { _ = cache.Set(ctx, ck, string(b), 60) } return info } // writeLoginLog 写入登录日志 // 覆盖登录日志表所有 NOT NULL 字段,写入失败仅记录警告不影响主流程 func (s *sAuth) writeLoginLog(ctx context.Context, username string, logType, status int, errMsg string, start time.Time, req *v1.LoginReq) { param := "" if req != nil { if b, err := json.Marshal(map[string]any{ "username": req.Username, "code": maskValue(req.Code), "key": req.Key, }); err == nil { param = string(b) } } result := "成功" if status != 0 { result = "失败" } // 登录/退出日志的 create_user 显式取本次登录账号: // 登录走免登录公开路由,ctx 中无登录身份,审计钩子取不到操作人 createUser := username if createUser == "" { createUser = consts.GetUsername(ctx) } _, err := dao.LoginLog.Ctx(ctx).Data(map[string]any{ "username": username, "log_type": logType, "source": consts.LoginSourceAdmin, "method": consts.GetReqMethod(ctx), "request_method": consts.GetReqMethod(ctx), "url": consts.GetReqURL(ctx), "param": param, "result": result, "ip": consts.GetClientIP(ctx), "location": ip2region.Location(consts.GetClientIP(ctx)), "os": ua.ParseOS(consts.GetUserAgent(ctx)), "browser": ua.ParseBrowser(consts.GetUserAgent(ctx)), "consume_time": int(time.Since(start).Milliseconds()), "status": status, "error": errMsg, "create_user": createUser, }).Insert() if err != nil { g.Log().Warningf(ctx, "登录日志写入失败: %v", err) } } // maskValue 敏感值脱敏 // 空值返回空串,非空统一返回掩码 func maskValue(s string) string { if s == "" { return "" } return "***" }