394 lines
11 KiB
Go
394 lines
11 KiB
Go
package applogic
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
|
||
"aijianzhan/platform/internal/agentcap"
|
||
"aijianzhan/platform/internal/authx"
|
||
"aijianzhan/platform/internal/meta"
|
||
"aijianzhan/platform/internal/smsstore"
|
||
"aijianzhan/platform/internal/svc"
|
||
"aijianzhan/platform/internal/types"
|
||
"aijianzhan/platform/internal/userstore"
|
||
)
|
||
|
||
type AuthLogic struct {
|
||
ctx context.Context
|
||
svcCtx *svc.ServiceContext
|
||
}
|
||
|
||
func NewAuthLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AuthLogic {
|
||
return &AuthLogic{ctx: ctx, svcCtx: svcCtx}
|
||
}
|
||
|
||
func (l *AuthLogic) IssueToken(req *types.TokenReq) (*types.TokenResp, error) {
|
||
gt := strings.TrimSpace(strings.ToLower(req.GrantType))
|
||
if gt == "client_credentials" || (req.ClientID != "" && req.ClientSecret != "") {
|
||
return l.IssueClientCredentials(req.ClientID, req.ClientSecret)
|
||
}
|
||
want := l.svcCtx.Config.Auth.IssueSecret
|
||
if want == "" {
|
||
want = l.svcCtx.JWT.AccessSecret
|
||
}
|
||
if req.Secret == "" || req.Secret != want {
|
||
return nil, fmt.Errorf("invalid issue secret")
|
||
}
|
||
role := req.Role
|
||
if role == "" {
|
||
role = authx.Role管理员
|
||
}
|
||
return l.issue(req.TenantID, req.UserID, role, "", "", 0)
|
||
}
|
||
|
||
func (l *AuthLogic) Register(req *types.RegisterReq) (*types.TokenResp, error) {
|
||
if l.phoneLoginOnly() {
|
||
return nil, fmt.Errorf("本系统仅支持手机号登录,请联系管理员开通账号并绑定手机")
|
||
}
|
||
if l.svcCtx.Users == nil {
|
||
return nil, fmt.Errorf("user store unavailable")
|
||
}
|
||
u, err := l.svcCtx.Users.Register(l.ctx, req.Username, req.Password, req.DisplayName)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return l.issueUser(u)
|
||
}
|
||
|
||
func (l *AuthLogic) phoneLoginOnly() bool {
|
||
// 有部署期限控制时,不必强制手机号登录
|
||
if l.svcCtx.Config.License.Enabled {
|
||
return false
|
||
}
|
||
return l.svcCtx.Config.Auth.PhoneLoginOnly
|
||
}
|
||
|
||
// PhoneLoginOnlyPolicy 对外暴露:是否强制仅手机号登录(License 开启时为 false)。
|
||
func (l *AuthLogic) PhoneLoginOnlyPolicy() bool {
|
||
return l.phoneLoginOnly()
|
||
}
|
||
|
||
func (l *AuthLogic) Login(req *types.LoginReq) (*types.TokenResp, error) {
|
||
if l.svcCtx.Users == nil {
|
||
return nil, fmt.Errorf("user store unavailable")
|
||
}
|
||
smsCode := strings.TrimSpace(req.SMSCode)
|
||
phone := strings.TrimSpace(req.Phone)
|
||
account := strings.TrimSpace(req.Username)
|
||
if account == "" {
|
||
account = phone
|
||
}
|
||
|
||
// 手机号 + 短信验证码
|
||
if smsCode != "" {
|
||
if phone == "" {
|
||
phone = account
|
||
}
|
||
ns, err := userstore.NormalizePhone(phone)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if l.svcCtx.SMS == nil {
|
||
return nil, fmt.Errorf("短信服务未启用")
|
||
}
|
||
if err := l.svcCtx.SMS.Consume(smsstore.PurposeLogin, ns, smsCode); err != nil {
|
||
return nil, err
|
||
}
|
||
u, err := l.svcCtx.Users.GetByPhone(l.ctx, ns)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("该手机号未绑定账号")
|
||
}
|
||
if u.Status == "disabled" {
|
||
return nil, fmt.Errorf("账号已停用")
|
||
}
|
||
return l.issueUser(u)
|
||
}
|
||
|
||
if strings.TrimSpace(req.Password) == "" {
|
||
return nil, fmt.Errorf("请填写密码或短信验证码")
|
||
}
|
||
|
||
// 仅手机号登录:必须用手机号+密码
|
||
if l.phoneLoginOnly() {
|
||
loginPhone := phone
|
||
if loginPhone == "" {
|
||
loginPhone = account
|
||
}
|
||
ns, err := userstore.NormalizePhone(loginPhone)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("请使用手机号登录")
|
||
}
|
||
u, err := l.svcCtx.Users.Login(l.ctx, ns, req.Password)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := l.enforceLoginPolicy(u, false); err != nil {
|
||
return nil, err
|
||
}
|
||
return l.issueUser(u)
|
||
}
|
||
|
||
// 兼容模式:用户名/手机号 + 密码
|
||
if account == "" {
|
||
return nil, fmt.Errorf("请填写用户名或手机号")
|
||
}
|
||
u, err := l.svcCtx.Users.Login(l.ctx, account, req.Password)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
viaUsername := !userstore.LooksLikePhone(account)
|
||
if err := l.enforceLoginPolicy(u, viaUsername); err != nil {
|
||
return nil, err
|
||
}
|
||
return l.issueUser(u)
|
||
}
|
||
|
||
func (l *AuthLogic) enforceLoginPolicy(u *userstore.User, viaUsername bool) error {
|
||
cfg := l.svcCtx.Config.Auth
|
||
phoneOnly := l.phoneLoginOnly()
|
||
requirePhone := cfg.RequirePhoneBound || phoneOnly
|
||
if requirePhone && strings.TrimSpace(u.Phone) == "" {
|
||
return fmt.Errorf("本部署要求绑定手机号后才能登录,请联系管理员")
|
||
}
|
||
if !viaUsername {
|
||
return nil
|
||
}
|
||
if phoneOnly || cfg.DisableUsernameLoginIfPhoneBound || u.UsernameLoginDisabled {
|
||
if strings.TrimSpace(u.Phone) == "" {
|
||
return fmt.Errorf("已禁用用户名登录,请联系管理员绑定手机号")
|
||
}
|
||
return fmt.Errorf("已禁用用户名登录,请使用手机号+密码或短信验证码")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
type SendSMSResult struct {
|
||
ExpiresIn int
|
||
RetryAfter int
|
||
DebugCode string
|
||
Message string
|
||
}
|
||
|
||
func (l *AuthLogic) SendLoginSMS(phone string) (*SendSMSResult, error) {
|
||
if l.svcCtx.Users == nil || l.svcCtx.SMS == nil {
|
||
return nil, fmt.Errorf("短信服务未启用")
|
||
}
|
||
provider := strings.ToLower(strings.TrimSpace(l.svcCtx.Config.SMS.Provider))
|
||
if provider == "off" || provider == "disabled" {
|
||
return nil, fmt.Errorf("短信登录未开启")
|
||
}
|
||
ns, err := userstore.NormalizePhone(phone)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if _, err := l.svcCtx.Users.GetByPhone(l.ctx, ns); err != nil {
|
||
return nil, fmt.Errorf("该手机号未绑定账号,请联系管理员绑定手机后再登录")
|
||
}
|
||
code, expiresIn, retryAfter, err := l.svcCtx.SMS.Issue(smsstore.PurposeLogin, ns)
|
||
if err != nil {
|
||
return &SendSMSResult{RetryAfter: retryAfter}, err
|
||
}
|
||
out := &SendSMSResult{
|
||
ExpiresIn: expiresIn,
|
||
Message: "验证码已发送",
|
||
}
|
||
if provider == "" || provider == "dev" {
|
||
log.Printf("[sms:dev] login code for %s = %s (expires %ds)", ns, code, expiresIn)
|
||
out.DebugCode = code
|
||
out.Message = "开发模式:验证码已写入服务日志(并返回 debug_code)"
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (l *AuthLogic) ChangePassword(oldPassword, newPassword string) error {
|
||
if l.svcCtx.Users == nil {
|
||
return fmt.Errorf("user store unavailable")
|
||
}
|
||
uid := authx.UserID(l.ctx)
|
||
if uid <= 0 {
|
||
return fmt.Errorf("未登录")
|
||
}
|
||
return l.svcCtx.Users.ChangePassword(l.ctx, uid, oldPassword, newPassword)
|
||
}
|
||
|
||
func (l *AuthLogic) BindPhone(phone string) (*userstore.User, error) {
|
||
if l.svcCtx.Users == nil {
|
||
return nil, fmt.Errorf("user store unavailable")
|
||
}
|
||
uid := authx.UserID(l.ctx)
|
||
if uid <= 0 {
|
||
return nil, fmt.Errorf("未登录")
|
||
}
|
||
if l.phoneLoginOnly() && strings.TrimSpace(phone) == "" {
|
||
return nil, fmt.Errorf("本部署仅支持手机号登录,不可解绑手机号")
|
||
}
|
||
return l.svcCtx.Users.BindPhone(l.ctx, uid, phone)
|
||
}
|
||
|
||
func (l *AuthLogic) SetUsernameLoginDisabled(disabled bool) (*userstore.User, error) {
|
||
if l.svcCtx.Users == nil {
|
||
return nil, fmt.Errorf("user store unavailable")
|
||
}
|
||
uid := authx.UserID(l.ctx)
|
||
if uid <= 0 {
|
||
return nil, fmt.Errorf("未登录")
|
||
}
|
||
return l.svcCtx.Users.SetUsernameLoginDisabled(l.ctx, uid, disabled)
|
||
}
|
||
|
||
func (l *AuthLogic) Me() (*userstore.User, error) {
|
||
if l.svcCtx.Users == nil {
|
||
return nil, fmt.Errorf("user store unavailable")
|
||
}
|
||
uid := authx.UserID(l.ctx)
|
||
if uid <= 0 {
|
||
return nil, fmt.Errorf("未登录")
|
||
}
|
||
return l.svcCtx.Users.GetByID(l.ctx, uid)
|
||
}
|
||
|
||
func (l *AuthLogic) issue(tenantID, userID int64, role, username, displayName string, orgUnitID int64) (*types.TokenResp, error) {
|
||
role = authx.NormalizeRole(role)
|
||
token, exp, err := authx.IssueToken(l.svcCtx.JWT, tenantID, userID, role, orgUnitID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
secret := l.svcCtx.Config.Agent.CapsuleSecret
|
||
if secret == "" {
|
||
secret = l.svcCtx.JWT.AccessSecret
|
||
}
|
||
return &types.TokenResp{
|
||
AccessToken: token,
|
||
TokenType: "Bearer",
|
||
ExpiresAt: exp,
|
||
TenantID: tenantID,
|
||
UserID: userID,
|
||
Username: username,
|
||
DisplayName: displayName,
|
||
Role: role,
|
||
OrgUnitID: orgUnitID,
|
||
AgentKey: agentcap.PublicAgentKey(secret, tenantID, userID),
|
||
}, nil
|
||
}
|
||
|
||
type CapsuleLogic struct {
|
||
ctx context.Context
|
||
svcCtx *svc.ServiceContext
|
||
}
|
||
|
||
func NewCapsuleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CapsuleLogic {
|
||
return &CapsuleLogic{ctx: ctx, svcCtx: svcCtx}
|
||
}
|
||
|
||
func (l *CapsuleLogic) Build(slug string) (*types.CapsuleResp, error) {
|
||
tenantID := authx.TenantID(l.ctx)
|
||
userID := authx.UserID(l.ctx)
|
||
if authx.Role(l.ctx) == authx.RoleAgent {
|
||
if l.svcCtx.Agents == nil {
|
||
return nil, fmt.Errorf("agent store unavailable")
|
||
}
|
||
ok, err := l.svcCtx.Agents.HasAppAccess(l.ctx, authx.AgentID(l.ctx), slug)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !ok {
|
||
return nil, fmt.Errorf("app not granted to agent")
|
||
}
|
||
}
|
||
app, err := l.svcCtx.Meta.GetBySlug(l.ctx, tenantID, slug)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if app.Status != meta.StatusPublished {
|
||
return nil, fmt.Errorf("app not published")
|
||
}
|
||
base := l.svcCtx.Config.PublicBaseURL
|
||
if base == "" {
|
||
base = fmt.Sprintf("http://127.0.0.1:%d", l.svcCtx.Config.Port)
|
||
}
|
||
desc := &agentcap.Descriptor{
|
||
Version: "1",
|
||
BaseURL: strings.TrimRight(base, "/"),
|
||
AppSlug: slug,
|
||
TenantHint: fmt.Sprintf("t%d", tenantID),
|
||
Auth: agentcap.AuthSpec{Type: "bearer_jwt", Header: "Authorization"},
|
||
Notes: "Decrypt with agent_key from /auth/token. Never expose plaintext API map in UI.",
|
||
}
|
||
for _, r := range app.Blueprint.Apis.Resources {
|
||
path := r.Path
|
||
if !strings.HasPrefix(path, "/") {
|
||
path = "/" + path
|
||
}
|
||
full := fmt.Sprintf("/api/v1/apps/%s%s", slug, path)
|
||
methods := make([]string, 0, len(r.Operations))
|
||
for _, op := range r.Operations {
|
||
switch op {
|
||
case "list", "get", "export":
|
||
methods = append(methods, "GET")
|
||
case "create", "import":
|
||
methods = append(methods, "POST")
|
||
case "update":
|
||
methods = append(methods, "PUT")
|
||
case "delete":
|
||
methods = append(methods, "DELETE")
|
||
}
|
||
}
|
||
var entityFields []agentcap.FieldSpec
|
||
pk := "id"
|
||
for _, e := range app.Blueprint.Entities {
|
||
if e.Name != r.Entity {
|
||
continue
|
||
}
|
||
pk = e.PrimaryKey
|
||
for _, f := range e.Fields {
|
||
entityFields = append(entityFields, agentcap.FieldSpec{Name: f.Name, Type: f.Type})
|
||
}
|
||
}
|
||
filters, sorts := []string{}, []string{}
|
||
if r.List != nil {
|
||
filters = r.List.AllowedFilters
|
||
sorts = r.List.AllowedSorts
|
||
}
|
||
name := strings.TrimPrefix(path, "/")
|
||
desc.Resources = append(desc.Resources, agentcap.ResourceSpec{
|
||
Name: name,
|
||
Path: full,
|
||
Methods: uniqStrings(methods),
|
||
Filters: filters,
|
||
Sorts: sorts,
|
||
Fields: entityFields,
|
||
PrimaryKey: pk,
|
||
})
|
||
}
|
||
|
||
secret := l.svcCtx.Config.Agent.CapsuleSecret
|
||
if secret == "" {
|
||
secret = l.svcCtx.JWT.AccessSecret
|
||
}
|
||
key := agentcap.DeriveKey(secret, tenantID, userID)
|
||
capsule, err := agentcap.Encrypt(key, desc)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &types.CapsuleResp{
|
||
Capsule: capsule,
|
||
Format: agentcap.Prefix,
|
||
Hint: "仅智能体使用 agent_key 解密;前端只展示密文",
|
||
}, nil
|
||
}
|
||
|
||
func uniqStrings(in []string) []string {
|
||
seen := map[string]struct{}{}
|
||
out := make([]string, 0, len(in))
|
||
for _, s := range in {
|
||
if _, ok := seen[s]; ok {
|
||
continue
|
||
}
|
||
seen[s] = struct{}{}
|
||
out = append(out, s)
|
||
}
|
||
return out
|
||
}
|