// +====================================================================== // | EasyGoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ] // +---------------------------------------------------------------------- // | Copyright (c) 2019-2026 半城风雨团队 版权所有 // +---------------------------------------------------------------------- // | 开源协议: Apache-2.0 // | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。 // +---------------------------------------------------------------------- // | 官方网站: https://www.easygoadmin.vip // +---------------------------------------------------------------------- // | 研发团队: 小蚂蚁云团队 荣誉出品 // +====================================================================== // | 版权和免责声明: // | 一、知识产权声明 // | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、 // | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权, // | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。 // | 二、使用限制与禁止条款 // | 您理解并同意,禁止将本产品用于以下任何行为: // | 1. 任何违反中华人民共和国法律法规的用途; // | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为; // | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。 // | 三、责任豁免 // | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失 // | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三 // | 方提起索赔或行政机关进行调查,您应独立承担全部责任。 // | 四、违约与法律责任 // | 如发现任何违反本声明的行为,本团队有权: // | 1. 无条件配合公安机关及其他有权机关的调查取证工作; // | 2. 依法追究相关方的民事责任,并要求赔偿全部损失; // | 3. 保留进一步采取刑事控告等法律措施的权利。 // | 五、许可范围说明 // | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包 // | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通 // | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、 // | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造 // | 成的一切经济损失及商誉损害。 // | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件 // | 许可协议》附件为准。 // +====================================================================== 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 "***" }