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 规则误放行(测试抓到)
122 lines
5.0 KiB
Go
122 lines
5.0 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
postgresstore "knowledge-governance/internal/platform/postgres"
|
|
)
|
|
|
|
const defaultSessionTTL = 12 * time.Hour
|
|
|
|
type loginRequest struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
func (config Config) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
var input loginRequest
|
|
if err := decodeStrictJSON(r, &input); err != nil || input.Username == "" || input.Password == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "username and password are required"})
|
|
return
|
|
}
|
|
outcome, err := config.Auth.Store.Login(r.Context(), strings.TrimSpace(input.Username), input.Password, r.UserAgent(), config.Now().UTC(), config.Auth.SessionTTL)
|
|
switch {
|
|
case errors.Is(err, postgresstore.ErrAuthInvalidCredential):
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid username or password"})
|
|
case errors.Is(err, postgresstore.ErrAuthLocked):
|
|
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "account temporarily locked, try again later"})
|
|
case errors.Is(err, postgresstore.ErrAuthUserDisabled):
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "account is disabled"})
|
|
case err != nil:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "login failed"})
|
|
default:
|
|
ttl := config.Auth.SessionTTL
|
|
if ttl <= 0 {
|
|
ttl = defaultSessionTTL
|
|
}
|
|
config.Auth.setSessionCookie(w, outcome.SessionToken, config.Now().UTC().Add(ttl))
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": userPayload(outcome.User)})
|
|
}
|
|
}
|
|
|
|
func userPayload(user postgresstore.AuthUser) map[string]any {
|
|
payload := map[string]any{
|
|
"id": user.ID, "username": user.Username, "display_name": user.DisplayName,
|
|
"role": user.Role, "state": user.State, "must_change_password": user.MustChangePassword,
|
|
}
|
|
if user.LastLoginAt != nil {
|
|
payload["last_login_at"] = user.LastLoginAt.UTC().Format(time.RFC3339)
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func (config Config) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
if identity, ok := sessionFrom(r.Context()); ok && identity.SessionID != "" {
|
|
_ = config.Auth.Store.Logout(r.Context(), identity.SessionID, config.Now().UTC())
|
|
}
|
|
config.Auth.clearSessionCookie(w)
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]string{"status": "logged_out"}})
|
|
}
|
|
|
|
func (config Config) handleMe(w http.ResponseWriter, r *http.Request) {
|
|
identity, ok := sessionFrom(r.Context())
|
|
if !ok {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{
|
|
"id": identity.UserID, "username": identity.Username, "display_name": identity.DisplayName,
|
|
"role": identity.Role, "must_change_password": identity.MustChangePassword,
|
|
}})
|
|
}
|
|
|
|
type changePasswordRequest struct {
|
|
CurrentPassword string `json:"current_password"`
|
|
NewPassword string `json:"new_password"`
|
|
}
|
|
|
|
func (config Config) handleChangePassword(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 changePasswordRequest
|
|
if err := decodeStrictJSON(r, &input); err != nil || input.CurrentPassword == "" || len(input.NewPassword) < 8 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "current_password and new_password (min 8 chars) are required"})
|
|
return
|
|
}
|
|
err := config.Auth.Store.ChangePassword(r.Context(), identity.UserID, input.CurrentPassword, input.NewPassword, config.Now().UTC())
|
|
switch {
|
|
case errors.Is(err, postgresstore.ErrAuthInvalidCredential):
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "current password is incorrect"})
|
|
case errors.Is(err, postgresstore.ErrAuthInvalidUser):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "new password must differ from the old one"})
|
|
case err != nil:
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "password change failed"})
|
|
default:
|
|
// ChangePassword revoked every session including this one; re-login
|
|
// with the new password so a forced first-login change keeps the
|
|
// operator inside the app instead of bouncing to the login screen.
|
|
outcome, loginErr := config.Auth.Store.Login(r.Context(), identity.Username, input.NewPassword, r.UserAgent(), config.Now().UTC(), config.Auth.SessionTTL)
|
|
if loginErr == nil {
|
|
ttl := config.Auth.SessionTTL
|
|
if ttl <= 0 {
|
|
ttl = defaultSessionTTL
|
|
}
|
|
config.Auth.setSessionCookie(w, outcome.SessionToken, config.Now().UTC().Add(ttl))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]bool{"changed": true}})
|
|
}
|
|
}
|
|
|
|
func (config Config) authRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("POST /api/v1/auth/login", config.handleLogin)
|
|
mux.HandleFunc("POST /api/v1/auth/logout", config.handleLogout)
|
|
mux.HandleFunc("GET /api/v1/auth/me", config.handleMe)
|
|
mux.HandleFunc("POST /api/v1/auth/change-password", config.handleChangePassword)
|
|
}
|