签入版本

This commit is contained in:
小蚂蚁云
2026-10-20 19:08:49 +08:00
commit 1f248e89e4
890 changed files with 119227 additions and 0 deletions
+494
View File
@@ -0,0 +1,494 @@
// +======================================================================
// | 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 "***"
}