feat: 完善平台总后台认证与管理入口
This commit is contained in:
@@ -1,12 +1,19 @@
|
||||
# Platform API
|
||||
|
||||
严格沿用 `sample/server` 的 BSM-SDK Core 分层和运行方式。运行前设置:
|
||||
平台总后台 API 严格沿用 `sample/server` 的 BSM-SDK Core 分层和运行方式。
|
||||
|
||||
启动前请设置运行配置;JWT 密钥必须为 16、24 或 32 个字符。
|
||||
|
||||
```powershell
|
||||
$env:BSM_RuntimeMode="dev"
|
||||
$env:BSM_Prefix="$(Get-Location)/etc"
|
||||
$env:BSM_JwtSecretKey="仅本地使用的随机密钥"
|
||||
$env:BSM_JwtSecretKey="local-dev-key-16"
|
||||
$env:HEQI_PLATFORM_ROOT_PASSWORD="请设置不少于12位的root初始密码"
|
||||
go run ./cmd/main/main.go
|
||||
```
|
||||
|
||||
`cmd/cli` 提供 `version` 与 `migrate`;受保护接口使用 `middleware.JwtAuth(true)`。UUID V7 主键及完整中文注释以 `../migrations` 为准。
|
||||
应用启动和 `go run ./cmd/cli/main.go migrate` 都会在事务内幂等创建平台 `root` 账号。账号名固定为 `root`;优先使用 `HEQI_PLATFORM_ROOT_PASSWORD`,未设置时仅使用开发环境默认值。root 首次登录后必须通过 `PUT /heqi/v1/auth/password` 修改密码。
|
||||
|
||||
匿名接口为 `POST /heqi/v1/auth/login`;其余平台接口经 `middleware.JwtAuth(true)` 保护。请求头 `Authorization` 直接传递 JWT 原始值,不使用 `Bearer` 前缀。
|
||||
|
||||
UUID V7 主键、模型中文注释和 PostgreSQL 变更记录以 `../migrations` 为准。
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/config"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/initdb"
|
||||
_ "git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
)
|
||||
|
||||
@@ -23,6 +24,9 @@ func main() {
|
||||
case "migrate":
|
||||
config.New(serviceKey)
|
||||
impl.NewImpl()
|
||||
if err := initdb.New(impl.DBService); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("platform database auto migrate completed")
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"git.apinb.com/bsm-sdk/core/printer"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/config"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/initdb"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/routers"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -24,6 +25,9 @@ const serviceKey = "heqi"
|
||||
func main() {
|
||||
config.New(serviceKey)
|
||||
impl.NewImpl()
|
||||
if err := initdb.New(impl.DBService); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
app := gin.Default()
|
||||
middleware.Mode(app)
|
||||
|
||||
11
backend/api/internal/initdb/new.go
Normal file
11
backend/api/internal/initdb/new.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Package initdb 提供应用启动后的基础数据初始化。
|
||||
package initdb
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
// New 在同一事务中初始化平台基础数据。
|
||||
func New(database *gorm.DB) error {
|
||||
return database.Transaction(func(tx *gorm.DB) error {
|
||||
return InitPlatformRoot(tx)
|
||||
})
|
||||
}
|
||||
57
backend/api/internal/initdb/platform.go
Normal file
57
backend/api/internal/initdb/platform.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package initdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// PlatformRootUsername 是平台总后台的内置根账号名称。
|
||||
PlatformRootUsername = "root"
|
||||
// PlatformRootPassword 是仅用于首次启动的初始密码,首次登录后必须修改。
|
||||
PlatformRootPassword = "Heqi@Root2026"
|
||||
// PlatformRootRoleCode 表示根账号的平台角色。
|
||||
PlatformRootRoleCode = "platform_root"
|
||||
)
|
||||
|
||||
// InitPlatformRoot 幂等创建平台总后台 root 账号。
|
||||
func InitPlatformRoot(database *gorm.DB) error {
|
||||
var account models.IdnAccount
|
||||
err := database.Where("username = ?", PlatformRootUsername).First(&account).Error
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(platformRootPassword()), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
account = models.IdnAccount{
|
||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: "enabled"},
|
||||
Username: PlatformRootUsername,
|
||||
DisplayName: "平台根管理员",
|
||||
PasswordHash: string(passwordHash),
|
||||
RoleCode: PlatformRootRoleCode,
|
||||
MustChangePassword: true,
|
||||
Phone: "",
|
||||
AccountType: "operator",
|
||||
ServiceArea: "全国",
|
||||
}
|
||||
return database.Create(&account).Error
|
||||
}
|
||||
|
||||
// platformRootPassword 优先读取部署环境传入的 root 初始密码。
|
||||
func platformRootPassword() string {
|
||||
if password := os.Getenv("HEQI_PLATFORM_ROOT_PASSWORD"); len(password) >= 12 {
|
||||
return password
|
||||
}
|
||||
return PlatformRootPassword
|
||||
}
|
||||
146
backend/api/internal/logic/platform/auth.go
Normal file
146
backend/api/internal/logic/platform/auth.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.apinb.com/bsm-sdk/core/crypto/token"
|
||||
"git.apinb.com/bsm-sdk/core/env"
|
||||
"git.apinb.com/bsm-sdk/core/errcode"
|
||||
"git.apinb.com/bsm-sdk/core/infra"
|
||||
"git.apinb.com/bsm-sdk/core/middleware"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LoginRequest 是平台总后台的账号密码登录请求。
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required,max=64"`
|
||||
Password string `json:"password" binding:"required,min=8,max=128"`
|
||||
}
|
||||
|
||||
// LoginReply 是后台登录成功后的访问凭证与账号状态。
|
||||
type LoginReply struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
Identity string `json:"identity"`
|
||||
DisplayName string `json:"display_name"`
|
||||
RoleCode string `json:"role_code"`
|
||||
MustChangePassword bool `json:"must_change_password"`
|
||||
}
|
||||
|
||||
// Login 校验平台账号密码并签发 BSM JWT。
|
||||
func Login(ctx *gin.Context) {
|
||||
var request LoginRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
|
||||
var account models.IdnAccount
|
||||
err := impl.DBService.Where("username = ?", strings.TrimSpace(request.Username)).First(&account).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
infra.Response.Error(ctx, errcode.ErrPassword)
|
||||
return
|
||||
}
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
if account.Status != "enabled" {
|
||||
infra.Response.Error(ctx, errcode.ErrAccountDisabled)
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(request.Password)) != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrPassword)
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := token.New(env.Runtime.JwtSecretKey).GenerateJwt(
|
||||
0,
|
||||
account.Identity.String(),
|
||||
"platform_admin",
|
||||
account.RoleCode,
|
||||
map[string]string{"username": account.Username, "display_name": account.DisplayName},
|
||||
map[string]string{"must_change_password": boolText(account.MustChangePassword)},
|
||||
)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, LoginReply{
|
||||
AccessToken: accessToken,
|
||||
TokenType: "JWT",
|
||||
Identity: account.Identity.String(),
|
||||
DisplayName: account.DisplayName,
|
||||
RoleCode: account.RoleCode,
|
||||
MustChangePassword: account.MustChangePassword,
|
||||
})
|
||||
}
|
||||
|
||||
// CurrentProfile 返回当前已认证的平台管理员资料。
|
||||
func CurrentProfile(ctx *gin.Context) {
|
||||
claims, err := middleware.ParseAuth(ctx)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
var account models.IdnAccount
|
||||
if err := impl.DBService.Where("identity = ?", claims.Identity).First(&account).Error; err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{
|
||||
"identity": account.Identity.String(), "username": account.Username, "display_name": account.DisplayName,
|
||||
"role_code": account.RoleCode, "must_change_password": account.MustChangePassword, "mfa_enabled": account.MFAEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
// ChangePasswordRequest 是已登录账号的改密请求。
|
||||
type ChangePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password" binding:"required,min=8,max=128"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=12,max=128"`
|
||||
}
|
||||
|
||||
// ChangePassword 修改当前账号密码并解除首次登录改密限制。
|
||||
func ChangePassword(ctx *gin.Context) {
|
||||
claims, err := middleware.ParseAuth(ctx)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
var request ChangePasswordRequest
|
||||
if err := ctx.ShouldBindJSON(&request); err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||
return
|
||||
}
|
||||
var account models.IdnAccount
|
||||
if err := impl.DBService.Where("identity = ?", claims.Identity).First(&account).Error; err != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(request.CurrentPassword)) != nil {
|
||||
infra.Response.Error(ctx, errcode.ErrPassword)
|
||||
return
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
if err := impl.DBService.Model(&account).Updates(map[string]any{"password_hash": string(passwordHash), "must_change_password": false}).Error; err != nil {
|
||||
infra.Response.Error(ctx, err)
|
||||
return
|
||||
}
|
||||
infra.Response.Success(ctx, gin.H{"changed": true})
|
||||
}
|
||||
|
||||
// boolText 将布尔值转换为 JWT 扩展字段约定的字符串。
|
||||
func boolText(value bool) string {
|
||||
if value {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
@@ -5,9 +5,15 @@ import "git.apinb.com/bsm-sdk/core/database"
|
||||
// IdnAccount 对应 idn_account,表示用户或服务人员身份账户。
|
||||
type IdnAccount struct {
|
||||
Entity
|
||||
Phone string `gorm:"column:phone;type:varchar(32);uniqueIndex;not null" json:"phone"` // 手机号,响应时需脱敏
|
||||
AccountType string `gorm:"column:account_type;type:varchar(32);not null" json:"account_type"` // 账户类型
|
||||
ServiceArea string `gorm:"column:service_area;type:varchar(128);not null" json:"service_area"` // 服务区域
|
||||
Username string `gorm:"column:username;type:varchar(64);uniqueIndex" json:"username"` // 登录用户名。
|
||||
DisplayName string `gorm:"column:display_name;type:varchar(64);not null;default:''" json:"display_name"` // 用户展示名称。
|
||||
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null;default:''" json:"-"` // 密码哈希值,禁止在接口中返回。
|
||||
RoleCode string `gorm:"column:role_code;type:varchar(64);not null;default:'user'" json:"role_code"` // 平台角色编码。
|
||||
MustChangePassword bool `gorm:"column:must_change_password;not null;default:false" json:"must_change_password"` // 是否必须修改初始密码。
|
||||
MFAEnabled bool `gorm:"column:mfa_enabled;not null;default:false" json:"mfa_enabled"` // 是否启用多因素认证。
|
||||
Phone string `gorm:"column:phone;type:varchar(32);uniqueIndex;not null" json:"phone"` // 手机号,用于登录和通知。
|
||||
AccountType string `gorm:"column:account_type;type:varchar(32);not null" json:"account_type"` // 账号类型,例如 user、operator。
|
||||
ServiceArea string `gorm:"column:service_area;type:varchar(128);not null" json:"service_area"` // 服务区域描述。
|
||||
}
|
||||
|
||||
func init() { database.AppendMigrate(&IdnAccount{}) }
|
||||
|
||||
@@ -14,10 +14,13 @@ func Register(srvKey string, engine *gin.Engine) {
|
||||
v1Key := fmt.Sprintf("/%s/%s", srvKey, "v1")
|
||||
anonymous := engine.Group(v1Key)
|
||||
anonymous.GET("/ping/hello", platform.PingHello)
|
||||
anonymous.POST("/auth/login", platform.Login)
|
||||
|
||||
protected := engine.Group(v1Key)
|
||||
protected.Use(middleware.JwtAuth(true))
|
||||
{
|
||||
protected.GET("/auth/profile", platform.CurrentProfile)
|
||||
protected.PUT("/auth/password", platform.ChangePassword)
|
||||
protected.GET("/dashboard/overview", platform.DashboardOverview)
|
||||
gasStationGroup := protected.Group("/organization/org_gas_station")
|
||||
gasStationGroup.POST("", platform.CreateOrgGasStation)
|
||||
|
||||
Reference in New Issue
Block a user