454 lines
15 KiB
Go
454 lines
15 KiB
Go
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 "***"
|
||
}
|