chore: initial commit of ai site platform
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
393
platform/internal/logic/applogic/auth.go
Normal file
393
platform/internal/logic/applogic/auth.go
Normal file
@@ -0,0 +1,393 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user