vdi-cloud/platform/internal/server/administrators.go

262 lines
8.3 KiB
Go
Raw Permalink 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 server
import (
"context"
"database/sql"
"errors"
"regexp"
"strings"
"unicode/utf8"
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
"golang.org/x/crypto/bcrypt"
"vdi-cloud/platform/internal/model"
"vdi-cloud/platform/internal/store"
)
func (s *Server) administratorList(c *gin.Context) {
items := []model.Administrator{}
if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT * FROM administrators ORDER BY username"); err != nil {
s.dbError(c, err)
return
}
list(c, items)
}
func validNewPassword(password string) bool {
return utf8.RuneCountInString(password) >= 12 && len(password) <= 72 && strings.TrimSpace(password) != ""
}
// All account writes share a transaction lock, then recheck the actor's current role.
// This also prevents concurrent administrators from disabling each other and leaving no owner.
func (s *Server) accountTransaction(c *gin.Context, superOnly bool) (*sqlx.Tx, model.Administrator) {
var actor model.Administrator
ctx := c.Request.Context()
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
s.dbError(c, err)
return nil, actor
}
if _, err = tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock(7651092028)"); err == nil {
err = tx.GetContext(ctx, &actor, "SELECT a.* FROM administrators a JOIN admin_sessions x ON x.admin_id=a.id WHERE a.id=$1 AND x.id=$2 AND x.expires_at>now() FOR UPDATE OF a", s.admin(c).ID, c.GetString("sessionId"))
}
if err != nil {
tx.Rollback()
if errors.Is(err, sql.ErrNoRows) {
s.fail(c, 401, "unauthorized", "登录已失效,请重新登录")
} else {
s.dbError(c, err)
}
return nil, actor
}
if !actor.Enabled || superOnly && actor.Role != "super_admin" {
tx.Rollback()
s.fail(c, 403, "forbidden", "当前账号没有该操作权限")
return nil, actor
}
return tx, actor
}
func accountAudit(ctx context.Context, tx *sqlx.Tx, actor, action, target, requestID string) error {
_, err := tx.ExecContext(ctx, "INSERT INTO audit_logs(id,actor,action,target,result,request_id) VALUES($1,$2,$3,$4,'success',$5)", store.ID("audit_"), actor, action, target, requestID)
return err
}
func (s *Server) createAdministrator(c *gin.Context) {
var in struct {
Username string `json:"username" binding:"required,max=80"`
DisplayName string `json:"displayName" binding:"required,max=80"`
Role string `json:"role" binding:"required,oneof=super_admin operator viewer"`
Password string `json:"password" binding:"required,max=72"`
}
if !s.decode(c, &in) {
return
}
in.Username = strings.TrimSpace(in.Username)
in.DisplayName = strings.TrimSpace(in.DisplayName)
if !regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{2,79}$`).MatchString(in.Username) || in.DisplayName == "" {
s.fail(c, 400, "invalid_account", "账号使用 3~80 位字母、数字、点、下划线或短横线,显示名称不能为空")
return
}
if !validNewPassword(in.Password) {
s.fail(c, 400, "invalid_password", "新密码至少 12 个字符,UTF-8 编码不超过 72 字节")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
s.dbError(c, err)
return
}
tx, actor := s.accountTransaction(c, true)
if tx == nil {
return
}
defer tx.Rollback()
var item model.Administrator
err = tx.GetContext(c.Request.Context(), &item, "INSERT INTO administrators(id,username,display_name,role,password_hash) VALUES($1,$2,$3,$4,$5) RETURNING *", store.ID("adm_"), in.Username, in.DisplayName, in.Role, string(hash))
if conflict(err) {
s.fail(c, 409, "account_conflict", "管理员账号已存在")
return
}
if err != nil {
s.dbError(c, err)
return
}
if err = accountAudit(c.Request.Context(), tx, actor.Username, "create_administrator", item.Username, c.GetString("requestId")); err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
c.JSON(201, item)
}
func (s *Server) updateAdministrator(c *gin.Context) {
var in struct {
DisplayName string `json:"displayName" binding:"required,max=80"`
Role string `json:"role" binding:"required,oneof=super_admin operator viewer"`
Enabled *bool `json:"enabled" binding:"required"`
}
if !s.decode(c, &in) {
return
}
in.DisplayName = strings.TrimSpace(in.DisplayName)
if in.DisplayName == "" {
s.fail(c, 400, "invalid_account", "显示名称不能为空")
return
}
tx, actor := s.accountTransaction(c, true)
if tx == nil {
return
}
defer tx.Rollback()
ctx := c.Request.Context()
var item model.Administrator
if err := tx.GetContext(ctx, &item, "SELECT * FROM administrators WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
if item.Enabled && item.Role == "super_admin" && (!*in.Enabled || in.Role != "super_admin") {
var count int
if err := tx.GetContext(ctx, &count, "SELECT count(*) FROM administrators WHERE enabled AND role='super_admin'"); err != nil {
s.dbError(c, err)
return
}
if count <= 1 {
s.fail(c, 409, "last_super_admin", "至少保留一个启用的超级管理员")
return
}
}
if item.ID == actor.ID && (!*in.Enabled || in.Role != actor.Role) {
s.fail(c, 409, "self_account_protected", "不能停用自己或修改自己的角色,请使用其他超级管理员账号操作")
return
}
invalidate := item.Role != in.Role || item.Enabled != *in.Enabled
err := tx.GetContext(ctx, &item, "UPDATE administrators SET display_name=$1,role=$2,enabled=$3 WHERE id=$4 RETURNING *", in.DisplayName, in.Role, *in.Enabled, item.ID)
if err == nil && invalidate {
_, err = tx.ExecContext(ctx, "DELETE FROM admin_sessions WHERE admin_id=$1", item.ID)
}
if err == nil {
err = accountAudit(ctx, tx, actor.Username, "update_administrator", item.Username, c.GetString("requestId"))
}
if err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
c.JSON(200, item)
}
func (s *Server) resetAdministratorPassword(c *gin.Context) {
var in struct {
Password string `json:"password" binding:"required,max=72"`
}
if !s.decode(c, &in) {
return
}
if !validNewPassword(in.Password) {
s.fail(c, 400, "invalid_password", "新密码至少 12 个字符,UTF-8 编码不超过 72 字节")
return
}
tx, actor := s.accountTransaction(c, true)
if tx == nil {
return
}
defer tx.Rollback()
if c.Param("id") == actor.ID {
s.fail(c, 409, "use_own_password", "修改本人密码请使用修改密码入口并验证原密码")
return
}
var item model.Administrator
ctx := c.Request.Context()
if err := tx.GetContext(ctx, &item, "SELECT * FROM administrators WHERE id=$1 FOR UPDATE", c.Param("id")); err != nil {
s.dbError(c, err)
return
}
if err := replacePassword(ctx, tx, item.ID, in.Password); err != nil {
s.dbError(c, err)
return
}
err := accountAudit(ctx, tx, actor.Username, "reset_administrator_password", item.Username, c.GetString("requestId"))
if err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
c.Status(204)
}
func replacePassword(ctx context.Context, tx *sqlx.Tx, id, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err == nil {
_, err = tx.ExecContext(ctx, "UPDATE administrators SET password_hash=$1 WHERE id=$2", string(hash), id)
}
if err == nil {
_, err = tx.ExecContext(ctx, "DELETE FROM admin_sessions WHERE admin_id=$1", id)
}
return err
}
func (s *Server) changeOwnPassword(c *gin.Context) {
var in struct {
CurrentPassword string `json:"currentPassword" binding:"required,max=200"`
NewPassword string `json:"newPassword" binding:"required,max=72"`
}
if !s.decode(c, &in) {
return
}
if !validNewPassword(in.NewPassword) {
s.fail(c, 400, "invalid_password", "新密码至少 12 个字符,UTF-8 编码不超过 72 字节")
return
}
tx, actor := s.accountTransaction(c, false)
if tx == nil {
return
}
defer tx.Rollback()
if bcrypt.CompareHashAndPassword([]byte(actor.PasswordHash), []byte(in.CurrentPassword)) != nil {
s.fail(c, 400, "current_password_invalid", "原密码不正确")
return
}
ctx := c.Request.Context()
if err := replacePassword(ctx, tx, actor.ID, in.NewPassword); err != nil {
s.dbError(c, err)
return
}
err := accountAudit(ctx, tx, actor.Username, "change_own_password", actor.Username, c.GetString("requestId"))
if err == nil {
err = tx.Commit()
}
if err != nil {
s.dbError(c, err)
return
}
c.Status(204)
}