fbe80ad784
上线账号权限管理(后端): - 迁移 000032:users + user_sessions(token 只存 sha256,服务端可即时撤销) - bcrypt 密码、8 次失败锁 15 分钟、统一 401 错误防用户名枚举 - 三角色 viewer/engineer/admin;HttpOnly+SameSite=Lax 会话 cookie,滑动续期 - 强制改密锁定:must_change_password 会话只能走 auth 端点;首登 dobotai - bootstrap:users 表为空时创建 admin(初始密码 dobotai,强制改密) - 生产双闸授权/回滚/失败重试收敛为 admin 角色;AuthorizedBy 用会话身份 - 全部业务写端点删除手填 actor 字段(operator_ref/reviewer_ref/created_by/ sealed_by/confirmed_by),服务端从会话注入;auth 关闭的开发 socket 回退 system - 成员管理 API:列表/创建/改角色/启停/重置密码,最后一名 active admin 保护 - RBAC 矩阵纯函数契约测试 + 中间件行为测试(登录/锁定/角色/审计戳) - 附带修复:GET /api/v1/users 曾被 GET=viewer 规则误放行(测试抓到)
171 lines
7.1 KiB
Go
171 lines
7.1 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
postgresstore "knowledge-governance/internal/platform/postgres"
|
|
)
|
|
|
|
// Member administration. Every route here is classified admin by the auth
|
|
// middleware; these handlers additionally require the session identity.
|
|
|
|
type createUserRequest struct {
|
|
Username string `json:"username"`
|
|
DisplayName string `json:"display_name"`
|
|
Role string `json:"role"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
func (config Config) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|
identity, ok := sessionFrom(r.Context())
|
|
if !ok {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
|
return
|
|
}
|
|
var input createUserRequest
|
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid user request"})
|
|
return
|
|
}
|
|
user, err := config.Auth.Store.CreateUser(r.Context(), strings.TrimSpace(input.Username), strings.TrimSpace(input.DisplayName), input.Role, input.Password, identity.UserID, config.Now().UTC())
|
|
switch {
|
|
case errors.Is(err, postgresstore.ErrAuthInvalidUser):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username, role or password does not meet policy"})
|
|
case errors.Is(err, postgresstore.ErrAuthDuplicateUsername):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "username already exists"})
|
|
case err != nil:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not create user"})
|
|
default:
|
|
writeJSON(w, http.StatusCreated, map[string]any{"data": userPayload(user)})
|
|
}
|
|
}
|
|
|
|
func (config Config) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
|
users, err := config.Auth.Store.ListAuthUsers(r.Context())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not list users"})
|
|
return
|
|
}
|
|
data := make([]map[string]any, 0, len(users))
|
|
for _, user := range users {
|
|
data = append(data, userPayload(user))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": data})
|
|
}
|
|
|
|
type updateUserRoleRequest struct {
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
func (config Config) handleUpdateUserRole(w http.ResponseWriter, r *http.Request) {
|
|
identity, _ := sessionFrom(r.Context())
|
|
var input updateUserRoleRequest
|
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid role request"})
|
|
return
|
|
}
|
|
err := config.Auth.Store.SetUserRole(r.Context(), identity.UserID, r.PathValue("id"), input.Role, config.Now().UTC())
|
|
switch {
|
|
case errors.Is(err, postgresstore.ErrAuthInvalidUser):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid role"})
|
|
case errors.Is(err, postgresstore.ErrAuthUserNotFound):
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
case errors.Is(err, postgresstore.ErrAuthLastAdmin):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "cannot change the last active admin"})
|
|
case err != nil:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not update role"})
|
|
default:
|
|
user, getErr := config.Auth.Store.GetAuthUserByID(r.Context(), r.PathValue("id"))
|
|
if getErr != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load user"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": userPayload(user)})
|
|
}
|
|
}
|
|
|
|
type updateUserStateRequest struct {
|
|
State string `json:"state"`
|
|
}
|
|
|
|
func (config Config) handleUpdateUserState(w http.ResponseWriter, r *http.Request) {
|
|
identity, _ := sessionFrom(r.Context())
|
|
var input updateUserStateRequest
|
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&input); err != nil || (input.State != "active" && input.State != "disabled") {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "state must be active or disabled"})
|
|
return
|
|
}
|
|
targetID := r.PathValue("id")
|
|
if targetID == identity.UserID {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot change your own account state"})
|
|
return
|
|
}
|
|
err := config.Auth.Store.SetUserState(r.Context(), identity.UserID, targetID, input.State, config.Now().UTC())
|
|
switch {
|
|
case errors.Is(err, postgresstore.ErrAuthUserNotFound):
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
case errors.Is(err, postgresstore.ErrAuthLastAdmin):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "cannot disable the last active admin"})
|
|
case err != nil:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not update account"})
|
|
default:
|
|
user, getErr := config.Auth.Store.GetAuthUserByID(r.Context(), targetID)
|
|
if getErr != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load user"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": userPayload(user)})
|
|
}
|
|
}
|
|
|
|
type resetUserPasswordRequest struct {
|
|
TemporaryPassword string `json:"temporary_password"`
|
|
}
|
|
|
|
func (config Config) handleResetUserPassword(w http.ResponseWriter, r *http.Request) {
|
|
identity, _ := sessionFrom(r.Context())
|
|
var input resetUserPasswordRequest
|
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&input); err != nil || len(input.TemporaryPassword) < 8 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "temporary_password must be at least 8 characters"})
|
|
return
|
|
}
|
|
targetID := r.PathValue("id")
|
|
if targetID == identity.UserID {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "use change-password for your own account"})
|
|
return
|
|
}
|
|
err := config.Auth.Store.AdminResetPassword(r.Context(), identity.UserID, targetID, input.TemporaryPassword, config.Now().UTC())
|
|
switch {
|
|
case errors.Is(err, postgresstore.ErrAuthUserNotFound):
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "user not found"})
|
|
case errors.Is(err, postgresstore.ErrAuthUserDisabled):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": "cannot reset a disabled account"})
|
|
case errors.Is(err, postgresstore.ErrAuthInvalidUser):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid temporary password"})
|
|
case err != nil:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not reset password"})
|
|
default:
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]bool{"reset": true, "must_change_password": true}})
|
|
}
|
|
}
|
|
|
|
func (config Config) userRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /api/v1/users", config.handleListUsers)
|
|
mux.HandleFunc("POST /api/v1/users", config.handleCreateUser)
|
|
mux.HandleFunc("POST /api/v1/users/{id}/role", config.handleUpdateUserRole)
|
|
mux.HandleFunc("POST /api/v1/users/{id}/state", config.handleUpdateUserState)
|
|
mux.HandleFunc("POST /api/v1/users/{id}/reset-password", config.handleResetUserPassword)
|
|
}
|