Files
ai_site/platform/internal/logic/applogic/auth.go
2026-07-31 10:19:22 +08:00

394 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}