feat: add customer authentication and scoped RBAC
This commit is contained in:
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -26,12 +27,18 @@ type tokenPrincipal struct {
|
||||
}
|
||||
|
||||
type apiAuthenticator struct {
|
||||
mode string
|
||||
tokens []tokenPrincipal
|
||||
mode string
|
||||
tokens []tokenPrincipal
|
||||
local *authStore
|
||||
adapters []IdentityAdapter
|
||||
}
|
||||
|
||||
func withAPIAuth(next http.Handler, cfg config.Config) http.Handler {
|
||||
authenticator, err := newAPIAuthenticator(cfg)
|
||||
func withAPIAuth(next http.Handler, cfg config.Config, databases ...*sql.DB) http.Handler {
|
||||
var db *sql.DB
|
||||
if len(databases) > 0 {
|
||||
db = databases[0]
|
||||
}
|
||||
authenticator, err := newAPIAuthenticator(cfg, db)
|
||||
if err != nil {
|
||||
log.Printf("platform API authentication misconfigured: %v", err)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -41,7 +48,7 @@ func withAPIAuth(next http.Handler, cfg config.Config) http.Handler {
|
||||
return authenticator.middleware(next)
|
||||
}
|
||||
|
||||
func newAPIAuthenticator(cfg config.Config) (*apiAuthenticator, error) {
|
||||
func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, error) {
|
||||
mode := strings.ToLower(strings.TrimSpace(cfg.AuthMode))
|
||||
if mode == "" {
|
||||
mode = "disabled"
|
||||
@@ -49,7 +56,11 @@ func newAPIAuthenticator(cfg config.Config) (*apiAuthenticator, error) {
|
||||
if mode != "disabled" && mode != "enforce" {
|
||||
return nil, fmt.Errorf("AUTH_MODE must be disabled or enforce")
|
||||
}
|
||||
authenticator := &apiAuthenticator{mode: mode}
|
||||
local, err := newAuthStore(db, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authenticator := &apiAuthenticator{mode: mode, local: local}
|
||||
configured := []configuredPrincipal{}
|
||||
if strings.TrimSpace(cfg.AuthTokensJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(cfg.AuthTokensJSON), &configured); err != nil {
|
||||
@@ -75,22 +86,46 @@ func newAPIAuthenticator(cfg config.Config) (*apiAuthenticator, error) {
|
||||
return nil, fmt.Errorf("duplicate authentication token")
|
||||
}
|
||||
seen[hash] = true
|
||||
authenticator.tokens = append(authenticator.tokens, tokenPrincipal{hash: hash, principal: platform.Principal{Name: item.Name, Role: item.Role}})
|
||||
principal := platform.Principal{Name: item.Name, Username: item.Name, Role: item.Role, UserType: item.Role, AuthProvider: "legacy-token", MenuKeys: append([]string(nil), adminMenus...)}
|
||||
authenticator.tokens = append(authenticator.tokens, tokenPrincipal{hash: hash, principal: principal})
|
||||
}
|
||||
if mode == "enforce" && len(authenticator.tokens) == 0 {
|
||||
return nil, fmt.Errorf("enforce mode requires AUTH_TOKEN or AUTH_TOKENS_JSON")
|
||||
if mode == "enforce" && len(authenticator.tokens) == 0 && authenticator.local == nil && len(authenticator.adapters) == 0 {
|
||||
return nil, fmt.Errorf("enforce mode requires a local identity store, identity adapter, AUTH_TOKEN, or AUTH_TOKENS_JSON")
|
||||
}
|
||||
return authenticator, nil
|
||||
}
|
||||
|
||||
func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v2/auth/login" {
|
||||
if a.local == nil {
|
||||
httpx.WriteError(w, http.StatusServiceUnavailable, "LOCAL_AUTH_UNAVAILABLE", "账号登录尚未启用", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "登录接口仅支持 POST", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
a.local.login(w, r)
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticate(r)
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="lingniu-vehicle-platform"`)
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "AUTH_REQUIRED", "需要有效的访问令牌", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if principal.UserType == "customer" && r.URL.Path != "/api/v2/session" && r.URL.Path != "/api/v2/auth/logout" && r.URL.Path != "/api/v2/auth/password" {
|
||||
menu := requiredMenu(r)
|
||||
allowed := principal.CanMenu(menu)
|
||||
if menu == "shared" {
|
||||
allowed = principal.CanMenu("monitor") || principal.CanMenu("vehicles") || principal.CanMenu("tracks") || principal.CanMenu("statistics")
|
||||
}
|
||||
if menu == "" || !allowed {
|
||||
httpx.WriteError(w, http.StatusForbidden, "MENU_PERMISSION_DENIED", "当前账号无权访问此功能", menu, requestTraceID(r))
|
||||
return
|
||||
}
|
||||
}
|
||||
required := requiredRole(r)
|
||||
if roleRank(principal.Role) < roleRank(required) {
|
||||
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r))
|
||||
@@ -99,11 +134,33 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
ctx := platform.WithPrincipal(r.Context(), principal)
|
||||
r = r.WithContext(ctx)
|
||||
if r.URL.Path == "/api/v2/session" {
|
||||
principal.VehicleCount = len(principal.VehicleVINs)
|
||||
httpx.WriteOK(w, requestTraceID(r), struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
platform.Principal
|
||||
AuthMode string `json:"authMode"`
|
||||
}{principal.Name, principal.Role, a.mode})
|
||||
}{principal, a.mode})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/v2/auth/logout" {
|
||||
if r.Method != http.MethodPost {
|
||||
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "退出接口仅支持 POST", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if a.local != nil && principal.AuthProvider == "local" {
|
||||
a.local.logout(r.Context(), bearerToken(r))
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]bool{"loggedOut": true})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/v2/auth/password" {
|
||||
if r.Method != http.MethodPut || a.local == nil {
|
||||
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "密码修改接口仅支持 PUT", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
a.local.changePassword(w, r, principal)
|
||||
return
|
||||
}
|
||||
if a.local != nil && a.local.handleAdmin(w, r, principal) {
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
@@ -112,16 +169,27 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
|
||||
func (a *apiAuthenticator) authenticate(r *http.Request) (platform.Principal, bool) {
|
||||
if a.mode == "disabled" {
|
||||
return platform.Principal{Name: "local-developer", Role: "admin"}, true
|
||||
return platform.Principal{Name: "local-developer", Username: "local-developer", Role: "admin", UserType: "admin", AuthProvider: "disabled", MenuKeys: append([]string(nil), adminMenus...)}, true
|
||||
}
|
||||
header := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if len(header) < 8 || !strings.EqualFold(header[:7], "Bearer ") {
|
||||
return platform.Principal{}, false
|
||||
}
|
||||
token := strings.TrimSpace(header[7:])
|
||||
token := bearerToken(r)
|
||||
if token == "" {
|
||||
return platform.Principal{}, false
|
||||
}
|
||||
if a.local != nil {
|
||||
if principal, ok := a.local.authenticate(r.Context(), token); ok {
|
||||
return principal, true
|
||||
}
|
||||
}
|
||||
for _, adapter := range a.adapters {
|
||||
principal, matched, err := adapter.AuthenticateBearer(r.Context(), token)
|
||||
if err != nil {
|
||||
log.Printf("identity adapter %s rejected credential: %v", adapter.Name(), err)
|
||||
continue
|
||||
}
|
||||
if matched {
|
||||
return principal, true
|
||||
}
|
||||
}
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
var match platform.Principal
|
||||
matched := 0
|
||||
@@ -135,6 +203,34 @@ func (a *apiAuthenticator) authenticate(r *http.Request) (platform.Principal, bo
|
||||
return match, matched == 1
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
header := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if len(header) < 8 || !strings.EqualFold(header[:7], "Bearer ") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(header[7:])
|
||||
}
|
||||
|
||||
func requiredMenu(r *http.Request) string {
|
||||
path := r.URL.Path
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/v2/monitor"), path == "/api/v2/alerts/events":
|
||||
return "monitor"
|
||||
case path == "/api/map/reverse-geocode", path == "/api/realtime/vehicles", path == "/api/realtime/locations", path == "/api/vehicle-service", path == "/api/vehicle-service/overview", strings.HasSuffix(path, "/telemetry/latest"):
|
||||
return "shared"
|
||||
case path == "/api/v2/tracks":
|
||||
return "tracks"
|
||||
case path == "/api/mileage/daily", path == "/api/mileage/summary", path == "/api/v2/statistics/mileage", path == "/api/vehicles/coverage", path == "/api/vehicles/coverage/summary":
|
||||
return "statistics"
|
||||
case path == "/api/vehicles", path == "/api/vehicles/resolve":
|
||||
return "shared"
|
||||
case strings.HasPrefix(path, "/api/v2/vehicles/"):
|
||||
return "vehicles"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func requiredRole(r *http.Request) string {
|
||||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.HasPrefix(r.URL.Path, "/api/v2/exports") {
|
||||
return "operator"
|
||||
@@ -160,12 +256,15 @@ func requiredRole(r *http.Request) string {
|
||||
if r.Method == http.MethodPut && (path == "/api/v2/access/thresholds" || strings.HasPrefix(path, "/api/v2/alerts/rules/")) {
|
||||
return "admin"
|
||||
}
|
||||
if r.Method == http.MethodPut && path == "/api/v2/auth/password" {
|
||||
return "viewer"
|
||||
}
|
||||
return "admin"
|
||||
}
|
||||
|
||||
func roleRank(role string) int {
|
||||
switch strings.ToLower(role) {
|
||||
case "viewer":
|
||||
case "viewer", "customer":
|
||||
return 1
|
||||
case "operator":
|
||||
return 2
|
||||
|
||||
747
vehicle-data-platform/apps/api/internal/app/auth_store.go
Normal file
747
vehicle-data-platform/apps/api/internal/app/auth_store.go
Normal file
@@ -0,0 +1,747 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
const (
|
||||
localSessionCacheTTL = 30 * time.Second
|
||||
loginLockDuration = 15 * time.Minute
|
||||
maxLoginFailures = 5
|
||||
)
|
||||
|
||||
var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{2,63}$`)
|
||||
|
||||
var customerMenuSet = map[string]bool{
|
||||
"monitor": true, "vehicles": true, "tracks": true, "statistics": true,
|
||||
}
|
||||
|
||||
var adminMenus = []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations", "users"}
|
||||
|
||||
type authUser struct {
|
||||
ID uint64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
UserType string `json:"userType"`
|
||||
Status string `json:"status"`
|
||||
CustomerRef string `json:"customerRef"`
|
||||
TenantRef string `json:"tenantRef"`
|
||||
AuthProvider string `json:"authProvider"`
|
||||
ExternalSubject string `json:"externalSubject,omitempty"`
|
||||
MenuKeys []string `json:"menuKeys"`
|
||||
VehicleVINs []string `json:"vehicleVins"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type localCredential struct {
|
||||
authUser
|
||||
PasswordHash string
|
||||
FailedLoginCount int
|
||||
LockedUntil sql.NullTime
|
||||
}
|
||||
|
||||
type userMutation struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
Status string `json:"status"`
|
||||
CustomerRef string `json:"customerRef"`
|
||||
TenantRef string `json:"tenantRef"`
|
||||
MenuKeys []string `json:"menuKeys"`
|
||||
VehicleVINs []string `json:"vehicleVins"`
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type passwordChangeRequest struct {
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Session platform.Principal `json:"session"`
|
||||
}
|
||||
|
||||
type cachedSession struct {
|
||||
principal platform.Principal
|
||||
expiresAt time.Time
|
||||
cachedAt time.Time
|
||||
}
|
||||
|
||||
type authStore struct {
|
||||
db *sql.DB
|
||||
sessionTTL time.Duration
|
||||
cacheMu sync.RWMutex
|
||||
cache map[string]cachedSession
|
||||
}
|
||||
|
||||
func newAuthStore(db *sql.DB, cfg config.Config) (*authStore, error) {
|
||||
if db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
ttl := cfg.SessionTTL
|
||||
if ttl <= 0 {
|
||||
ttl = 12 * time.Hour
|
||||
}
|
||||
store := &authStore{db: db, sessionTTL: ttl, cache: map[string]cachedSession{}}
|
||||
if err := store.ensureBootstrapAdmin(context.Background(), cfg.BootstrapAdminUsername, cfg.BootstrapAdminPassword); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *authStore) ensureBootstrapAdmin(ctx context.Context, username, password string) error {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" && password == "" {
|
||||
return nil
|
||||
}
|
||||
if !usernamePattern.MatchString(username) {
|
||||
return fmt.Errorf("BOOTSTRAP_ADMIN_USERNAME is invalid")
|
||||
}
|
||||
if err := validatePassword(password); err != nil {
|
||||
return fmt.Errorf("BOOTSTRAP_ADMIN_PASSWORD: %w", err)
|
||||
}
|
||||
var exists int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM platform_user WHERE user_type='admin'`).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("check bootstrap admin: %w", err)
|
||||
}
|
||||
if exists > 0 {
|
||||
return nil
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash bootstrap password: %w", err)
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO platform_user(username,display_name,password_hash,user_type,status,auth_provider,created_by,updated_by) VALUES(?,?,?,'admin','enabled','local','bootstrap','bootstrap')`, username, "平台管理员", string(hash))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create bootstrap admin: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *authStore) login(w http.ResponseWriter, r *http.Request) {
|
||||
var input loginRequest
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
input.Username = strings.TrimSpace(input.Username)
|
||||
if !usernamePattern.MatchString(input.Username) || len(input.Password) > 128 {
|
||||
s.audit(r.Context(), input.Username, "login", "user", input.Username, "denied", map[string]any{"reason": "invalid_credentials"}, remoteAddress(r))
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "LOGIN_FAILED", "用户名或密码错误", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
credential, err := s.localCredential(r.Context(), input.Username)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "AUTH_STORE_FAILED", "登录服务暂时不可用", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
s.audit(r.Context(), input.Username, "login", "user", input.Username, "denied", map[string]any{"reason": "invalid_credentials"}, remoteAddress(r))
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "LOGIN_FAILED", "用户名或密码错误", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if credential.Status != "enabled" {
|
||||
s.audit(r.Context(), input.Username, "login", "user", strconv.FormatUint(credential.ID, 10), "denied", map[string]any{"reason": "disabled"}, remoteAddress(r))
|
||||
httpx.WriteError(w, http.StatusForbidden, "ACCOUNT_DISABLED", "账号已停用,请联系管理员", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if credential.LockedUntil.Valid && credential.LockedUntil.Time.After(now) {
|
||||
httpx.WriteError(w, http.StatusTooManyRequests, "ACCOUNT_LOCKED", "登录失败次数过多,请稍后再试", credential.LockedUntil.Time.Format(time.RFC3339), requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(credential.PasswordHash), []byte(input.Password)) != nil {
|
||||
failures := credential.FailedLoginCount + 1
|
||||
var locked any
|
||||
if failures >= maxLoginFailures {
|
||||
locked = now.Add(loginLockDuration)
|
||||
}
|
||||
_, _ = s.db.ExecContext(r.Context(), `UPDATE platform_user SET failed_login_count=?,locked_until=? WHERE id=?`, failures, locked, credential.ID)
|
||||
s.audit(r.Context(), input.Username, "login", "user", strconv.FormatUint(credential.ID, 10), "denied", map[string]any{"reason": "invalid_credentials", "failures": failures}, remoteAddress(r))
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "LOGIN_FAILED", "用户名或密码错误", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
principal, err := s.principalForUser(r.Context(), credential.authUser)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "AUTH_STORE_FAILED", "登录服务暂时不可用", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
accessToken, tokenHash, err := randomSessionToken()
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
sessionID, err := randomHex(16)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
expiresAt := now.Add(s.sessionTTL)
|
||||
_, err = s.db.ExecContext(r.Context(), `INSERT INTO platform_user_session(id,user_id,token_hash,issued_at,expires_at,last_seen_at,remote_addr,user_agent) VALUES(?,?,?,?,?,?,?,?)`, sessionID, credential.ID, tokenHash[:], now, expiresAt, now, remoteAddress(r), truncateUTF8(r.UserAgent(), 255))
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
_, _ = s.db.ExecContext(r.Context(), `UPDATE platform_user SET failed_login_count=0,locked_until=NULL,last_login_at=? WHERE id=?`, now, credential.ID)
|
||||
principal.SessionID = sessionID
|
||||
s.cachePut(hex.EncodeToString(tokenHash[:]), principal, expiresAt)
|
||||
s.audit(r.Context(), principal.Name, "login", "user", strconv.FormatUint(credential.ID, 10), "success", nil, remoteAddress(r))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
httpx.WriteOK(w, requestTraceID(r), loginResponse{AccessToken: accessToken, ExpiresAt: expiresAt, Session: principal})
|
||||
}
|
||||
|
||||
func (s *authStore) authenticate(ctx context.Context, token string) (platform.Principal, bool) {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
key := hex.EncodeToString(hash[:])
|
||||
if principal, ok := s.cacheGet(key); ok {
|
||||
return principal, true
|
||||
}
|
||||
var user authUser
|
||||
var sessionID string
|
||||
var expiresAt time.Time
|
||||
var external sql.NullString
|
||||
err := s.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.display_name,u.user_type,u.status,u.customer_ref,u.tenant_ref,u.auth_provider,u.external_subject,u.last_login_at,u.created_at,u.updated_at,s.id,s.expires_at FROM platform_user_session s JOIN platform_user u ON u.id=s.user_id WHERE s.token_hash=? AND s.revoked_at IS NULL AND s.expires_at>NOW(3) AND u.status='enabled'`, hash[:]).Scan(
|
||||
&user.ID, &user.Username, &user.DisplayName, &user.UserType, &user.Status, &user.CustomerRef, &user.TenantRef, &user.AuthProvider, &external, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, &sessionID, &expiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return platform.Principal{}, false
|
||||
}
|
||||
user.ExternalSubject = external.String
|
||||
principal, err := s.principalForUser(ctx, user)
|
||||
if err != nil {
|
||||
return platform.Principal{}, false
|
||||
}
|
||||
principal.SessionID = sessionID
|
||||
s.cachePut(key, principal, expiresAt)
|
||||
return principal, true
|
||||
}
|
||||
|
||||
func (s *authStore) logout(ctx context.Context, token string) {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE platform_user_session SET revoked_at=NOW(3) WHERE token_hash=? AND revoked_at IS NULL`, hash[:])
|
||||
s.cacheMu.Lock()
|
||||
delete(s.cache, hex.EncodeToString(hash[:]))
|
||||
s.cacheMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *authStore) changePassword(w http.ResponseWriter, r *http.Request, principal platform.Principal) {
|
||||
if principal.AuthProvider != "local" || principal.SubjectID == "" {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "PASSWORD_CHANGE_UNAVAILABLE", "当前登录方式不支持修改密码", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
var input passwordChangeRequest
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
if err := validatePassword(input.NewPassword); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "PASSWORD_INVALID", err.Error(), "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(principal.SubjectID, 10, 64)
|
||||
var currentHash string
|
||||
if err := s.db.QueryRowContext(r.Context(), `SELECT password_hash FROM platform_user WHERE id=? AND status='enabled'`, id).Scan(¤tHash); err != nil || bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(input.CurrentPassword)) != nil {
|
||||
s.audit(r.Context(), principal.Name, "password.change", "user", principal.SubjectID, "denied", map[string]any{"reason": "current_password_invalid"}, remoteAddress(r))
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "CURRENT_PASSWORD_INVALID", "当前密码不正确", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(input.NewPassword)) == nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "PASSWORD_UNCHANGED", "新密码不能与当前密码相同", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(input.NewPassword), 12)
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "PASSWORD_CHANGE_FAILED", "无法修改密码", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE platform_user SET password_hash=?,password_changed_at=NOW(3),failed_login_count=0,locked_until=NULL,updated_by=? WHERE id=?`, string(hash), principal.Name, id); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "PASSWORD_CHANGE_FAILED", "无法修改密码", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE platform_user_session SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, id); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "PASSWORD_CHANGE_FAILED", "无法撤销旧会话", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "PASSWORD_CHANGE_FAILED", "无法修改密码", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
s.invalidateUser(id)
|
||||
s.audit(r.Context(), principal.Name, "password.change", "user", principal.SubjectID, "success", nil, remoteAddress(r))
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]bool{"changed": true})
|
||||
}
|
||||
|
||||
func (s *authStore) localCredential(ctx context.Context, username string) (localCredential, error) {
|
||||
var credential localCredential
|
||||
var external sql.NullString
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id,username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,external_subject,failed_login_count,locked_until,last_login_at,created_at,updated_at FROM platform_user WHERE username=? AND auth_provider='local'`, username).Scan(
|
||||
&credential.ID, &credential.Username, &credential.DisplayName, &credential.PasswordHash, &credential.UserType, &credential.Status, &credential.CustomerRef, &credential.TenantRef, &credential.AuthProvider, &external, &credential.FailedLoginCount, &credential.LockedUntil, &credential.LastLoginAt, &credential.CreatedAt, &credential.UpdatedAt,
|
||||
)
|
||||
credential.ExternalSubject = external.String
|
||||
return credential, err
|
||||
}
|
||||
|
||||
func (s *authStore) principalForUser(ctx context.Context, user authUser) (platform.Principal, error) {
|
||||
menus := append([]string(nil), adminMenus...)
|
||||
vehicles := []string{}
|
||||
if user.UserType == "customer" {
|
||||
menus = []string{}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`, user.ID)
|
||||
if err != nil {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var value string
|
||||
if err := rows.Scan(&value); err != nil {
|
||||
rows.Close()
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
if customerMenuSet[value] {
|
||||
menus = append(menus, value)
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT vin FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`, user.ID)
|
||||
if err != nil {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
if err := rows.Scan(&vin); err != nil {
|
||||
rows.Close()
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
vehicles = append(vehicles, vin)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
}
|
||||
return platform.Principal{
|
||||
SubjectID: strconv.FormatUint(user.ID, 10), Name: user.DisplayName, Username: user.Username,
|
||||
Role: user.UserType, UserType: user.UserType, CustomerRef: user.CustomerRef, TenantRef: user.TenantRef,
|
||||
AuthProvider: user.AuthProvider, MenuKeys: menus, VehicleVINs: vehicles,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *authStore) handleAdmin(w http.ResponseWriter, r *http.Request, principal platform.Principal) bool {
|
||||
if r.URL.Path != "/api/v2/admin/users" && !strings.HasPrefix(r.URL.Path, "/api/v2/admin/users/") {
|
||||
return false
|
||||
}
|
||||
if principal.UserType != "admin" && principal.Role != "admin" {
|
||||
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "仅管理员可以管理账号与权限", "", requestTraceID(r))
|
||||
return true
|
||||
}
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/admin/users":
|
||||
s.listUsers(w, r)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v2/admin/users":
|
||||
s.createCustomer(w, r, principal)
|
||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v2/admin/users/"):
|
||||
s.updateCustomer(w, r, principal)
|
||||
default:
|
||||
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "不支持的账号管理操作", "", requestTraceID(r))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *authStore) listUsers(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := s.db.QueryContext(r.Context(), `SELECT id,username,display_name,user_type,status,customer_ref,tenant_ref,auth_provider,external_subject,last_login_at,created_at,updated_at FROM platform_user ORDER BY user_type ASC,created_at DESC`)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_LIST_FAILED", "无法读取账号列表", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
users := []authUser{}
|
||||
for rows.Next() {
|
||||
var user authUser
|
||||
var external sql.NullString
|
||||
if err := rows.Scan(&user.ID, &user.Username, &user.DisplayName, &user.UserType, &user.Status, &user.CustomerRef, &user.TenantRef, &user.AuthProvider, &external, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_LIST_FAILED", "无法读取账号列表", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
user.ExternalSubject = external.String
|
||||
principal, err := s.principalForUser(r.Context(), user)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_LIST_FAILED", "无法读取账号权限", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
user.MenuKeys = principal.MenuKeys
|
||||
user.VehicleVINs = principal.VehicleVINs
|
||||
users = append(users, user)
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), users)
|
||||
}
|
||||
|
||||
func (s *authStore) createCustomer(w http.ResponseWriter, r *http.Request, actor platform.Principal) {
|
||||
var input userMutation
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "enabled")
|
||||
menus, vins, err := s.validateMutation(r.Context(), input, true)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "USER_INPUT_INVALID", err.Error(), "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法创建客户账号", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(r.Context(), `INSERT INTO platform_user(username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,created_by,updated_by) VALUES(?,?,?,'customer',?,?,?,'local',?,?)`, strings.TrimSpace(input.Username), strings.TrimSpace(input.DisplayName), string(hash), input.Status, strings.TrimSpace(input.CustomerRef), strings.TrimSpace(input.TenantRef), actor.Name, actor.Name)
|
||||
if err != nil {
|
||||
writeUserMutationError(w, r, err, "创建")
|
||||
return
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
if err := replaceGrants(r.Context(), tx, uint64(id), menus, vins, actor.Name); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法保存客户权限", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法创建客户账号", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
s.audit(r.Context(), actor.Name, "user.create", "user", strconv.FormatInt(id, 10), "success", map[string]any{"menus": menus, "vehicleCount": len(vins)}, remoteAddress(r))
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (s *authStore) updateCustomer(w http.ResponseWriter, r *http.Request, actor platform.Principal) {
|
||||
idText := strings.TrimPrefix(r.URL.Path, "/api/v2/admin/users/")
|
||||
id, err := strconv.ParseUint(idText, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "USER_ID_INVALID", "账号编号无效", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
var input userMutation
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "enabled")
|
||||
menus, vins, err := s.validateMutation(r.Context(), input, false)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "USER_INPUT_INVALID", err.Error(), "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_UPDATE_FAILED", "无法更新客户账号", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var userType string
|
||||
if err := tx.QueryRowContext(r.Context(), `SELECT user_type FROM platform_user WHERE id=? FOR UPDATE`, id).Scan(&userType); err != nil || userType != "customer" {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "CUSTOMER_USER_REQUIRED", "只能通过此功能维护客户账号", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
args := []any{strings.TrimSpace(input.DisplayName), input.Status, strings.TrimSpace(input.CustomerRef), strings.TrimSpace(input.TenantRef), actor.Name}
|
||||
query := `UPDATE platform_user SET display_name=?,status=?,customer_ref=?,tenant_ref=?,updated_by=?`
|
||||
if input.Password != "" {
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
|
||||
query += `,password_hash=?,password_changed_at=NOW(3),failed_login_count=0,locked_until=NULL`
|
||||
args = append(args, string(hash))
|
||||
}
|
||||
query += ` WHERE id=?`
|
||||
args = append(args, id)
|
||||
if _, err := tx.ExecContext(r.Context(), query, args...); err != nil {
|
||||
writeUserMutationError(w, r, err, "更新")
|
||||
return
|
||||
}
|
||||
if err := replaceGrants(r.Context(), tx, id, menus, vins, actor.Name); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_UPDATE_FAILED", "无法保存客户权限", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if input.Status == "disabled" || input.Password != "" {
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE platform_user_session SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, id); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_UPDATE_FAILED", "无法撤销旧会话", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_UPDATE_FAILED", "无法更新客户账号", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
s.invalidateUser(id)
|
||||
s.audit(r.Context(), actor.Name, "user.update", "user", idText, "success", map[string]any{"status": input.Status, "menus": menus, "vehicleCount": len(vins), "passwordReset": input.Password != ""}, remoteAddress(r))
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (s *authStore) validateMutation(ctx context.Context, input userMutation, requirePassword bool) ([]string, []string, error) {
|
||||
if requirePassword && !usernamePattern.MatchString(strings.TrimSpace(input.Username)) {
|
||||
return nil, nil, fmt.Errorf("用户名需为 3-64 位字母、数字、点、下划线或短横线")
|
||||
}
|
||||
if strings.TrimSpace(input.DisplayName) == "" || len([]rune(strings.TrimSpace(input.DisplayName))) > 48 {
|
||||
return nil, nil, fmt.Errorf("客户名称不能为空且不能超过 48 个字符")
|
||||
}
|
||||
if input.Status != "enabled" && input.Status != "disabled" {
|
||||
return nil, nil, fmt.Errorf("账号状态无效")
|
||||
}
|
||||
if requirePassword || input.Password != "" {
|
||||
if err := validatePassword(input.Password); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
menus := normalizeMenus(input.MenuKeys)
|
||||
if len(menus) == 0 {
|
||||
return nil, nil, fmt.Errorf("至少分配一个客户菜单")
|
||||
}
|
||||
vins := normalizeVINs(input.VehicleVINs)
|
||||
if len(vins) == 0 {
|
||||
return nil, nil, fmt.Errorf("至少分配一辆可查看车辆")
|
||||
}
|
||||
if len(vins) > 5000 {
|
||||
return nil, nil, fmt.Errorf("单个客户最多分配 5000 辆车")
|
||||
}
|
||||
found, err := existingVINs(ctx, s.db, vins)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("校验车辆失败")
|
||||
}
|
||||
if len(found) != len(vins) {
|
||||
missing := []string{}
|
||||
for _, vin := range vins {
|
||||
if !found[vin] {
|
||||
missing = append(missing, vin)
|
||||
}
|
||||
if len(missing) == 5 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, nil, fmt.Errorf("存在未接入的 VIN:%s", strings.Join(missing, "、"))
|
||||
}
|
||||
return menus, vins, nil
|
||||
}
|
||||
|
||||
func replaceGrants(ctx context.Context, tx *sql.Tx, userID uint64, menus, vins []string, actor string) error {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM platform_user_menu WHERE user_id=?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, menu := range menus {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?,?)`, userID, menu, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM platform_user_vehicle WHERE user_id=?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, vin := range vins {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO platform_user_vehicle(user_id,vin,source_system,granted_by) VALUES(?,?,'manual',?)`, userID, vin, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func existingVINs(ctx context.Context, db *sql.DB, vins []string) (map[string]bool, error) {
|
||||
result := map[string]bool{}
|
||||
for start := 0; start < len(vins); start += 500 {
|
||||
end := start + 500
|
||||
if end > len(vins) {
|
||||
end = len(vins)
|
||||
}
|
||||
part := vins[start:end]
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(part)), ",")
|
||||
args := make([]any, len(part))
|
||||
for index := range part {
|
||||
args[index] = part[index]
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `SELECT vin FROM (SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin<>'' UNION SELECT vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin<>'') v WHERE vin IN (`+placeholders+`)`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
if err := rows.Scan(&vin); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
result[strings.ToUpper(strings.TrimSpace(vin))] = true
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeMenus(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
menus := []string{}
|
||||
for _, value := range values {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if customerMenuSet[value] && !seen[value] {
|
||||
seen[value] = true
|
||||
menus = append(menus, value)
|
||||
}
|
||||
}
|
||||
sort.Strings(menus)
|
||||
return menus
|
||||
}
|
||||
|
||||
func normalizeVINs(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
vins := []string{}
|
||||
for _, value := range values {
|
||||
for _, part := range strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == ',' || r == ';' || r == ';' || r == '\n' || r == '\t' || r == ' '
|
||||
}) {
|
||||
vin := strings.ToUpper(strings.TrimSpace(part))
|
||||
if vin != "" && len(vin) <= 64 && !seen[vin] {
|
||||
seen[vin] = true
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(vins)
|
||||
return vins
|
||||
}
|
||||
|
||||
func validatePassword(password string) error {
|
||||
if len(password) < 10 || len(password) > 128 {
|
||||
return fmt.Errorf("密码长度需为 10-128 位")
|
||||
}
|
||||
classes := 0
|
||||
if regexp.MustCompile(`[a-z]`).MatchString(password) {
|
||||
classes++
|
||||
}
|
||||
if regexp.MustCompile(`[A-Z]`).MatchString(password) {
|
||||
classes++
|
||||
}
|
||||
if regexp.MustCompile(`[0-9]`).MatchString(password) {
|
||||
classes++
|
||||
}
|
||||
if regexp.MustCompile(`[^A-Za-z0-9]`).MatchString(password) {
|
||||
classes++
|
||||
}
|
||||
if classes < 3 {
|
||||
return fmt.Errorf("密码需包含大小写字母、数字、特殊字符中的至少三类")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func randomSessionToken() (string, [sha256.Size]byte, error) {
|
||||
var raw [32]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return "", [sha256.Size]byte{}, err
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(raw[:])
|
||||
return token, sha256.Sum256([]byte(token)), nil
|
||||
}
|
||||
|
||||
func randomHex(size int) (string, error) {
|
||||
raw := make([]byte, size)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
func (s *authStore) cacheGet(key string) (platform.Principal, bool) {
|
||||
s.cacheMu.RLock()
|
||||
entry, ok := s.cache[key]
|
||||
s.cacheMu.RUnlock()
|
||||
now := time.Now()
|
||||
if !ok || now.After(entry.expiresAt) || now.Sub(entry.cachedAt) > localSessionCacheTTL {
|
||||
return platform.Principal{}, false
|
||||
}
|
||||
return entry.principal.Clone(), true
|
||||
}
|
||||
|
||||
func (s *authStore) cachePut(key string, principal platform.Principal, expiresAt time.Time) {
|
||||
s.cacheMu.Lock()
|
||||
s.cache[key] = cachedSession{principal: principal.Clone(), expiresAt: expiresAt, cachedAt: time.Now()}
|
||||
s.cacheMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *authStore) invalidateUser(userID uint64) {
|
||||
subject := strconv.FormatUint(userID, 10)
|
||||
s.cacheMu.Lock()
|
||||
for key, entry := range s.cache {
|
||||
if entry.principal.SubjectID == subject {
|
||||
delete(s.cache, key)
|
||||
}
|
||||
}
|
||||
s.cacheMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *authStore) audit(ctx context.Context, actor, action, targetType, targetID, result string, detail any, remote string) {
|
||||
var detailJSON any
|
||||
if detail != nil {
|
||||
if encoded, err := json.Marshal(detail); err == nil {
|
||||
detailJSON = encoded
|
||||
}
|
||||
}
|
||||
_, _ = s.db.ExecContext(ctx, `INSERT INTO platform_auth_audit(actor,action,target_type,target_id,result,detail_json,remote_addr) VALUES(?,?,?,?,?,?,?)`, truncateUTF8(actor, 96), action, targetType, targetID, result, detailJSON, truncateUTF8(remote, 96))
|
||||
}
|
||||
|
||||
func decodeAuthJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
defer r.Body.Close()
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "BAD_JSON", "请求 JSON 解析失败", "", requestTraceID(r))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func remoteAddress(r *http.Request) string {
|
||||
if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); forwarded != "" {
|
||||
return forwarded
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
func truncateUTF8(value string, max int) string {
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
if len(runes) > max {
|
||||
runes = runes[:max]
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
func writeUserMutationError(w http.ResponseWriter, r *http.Request, err error, action string) {
|
||||
message := "无法" + action + "客户账号"
|
||||
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
||||
message = "用户名已存在"
|
||||
}
|
||||
httpx.WriteError(w, http.StatusConflict, "USER_MUTATION_FAILED", message, "", requestTraceID(r))
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
// IdentityAdapter is the narrow trust boundary for a future Yudao Cloud,
|
||||
// RuoYi Sys or company SSO integration. Implementations must validate a signed,
|
||||
// short-lived credential (or use server-side introspection) before returning a
|
||||
// principal. Unsigned forwarding headers must never implement this interface.
|
||||
//
|
||||
// External identities should be mapped to platform_user through
|
||||
// (auth_provider, external_subject); menu and vehicle grants remain platform
|
||||
// authorization data so changing an upstream login system cannot bypass Scope.
|
||||
type IdentityAdapter interface {
|
||||
Name() string
|
||||
AuthenticateBearer(context.Context, string) (platform.Principal, bool, error)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ func NewServer(cfg config.Config) http.Handler {
|
||||
dataMode = "mock"
|
||||
}
|
||||
var store platform.Store = platform.NewMockStore()
|
||||
var authDB *sql.DB
|
||||
var storeErr error
|
||||
if dataMode != "mock" && dataMode != "production" {
|
||||
storeErr = fmt.Errorf("DATA_MODE must be mock or production")
|
||||
@@ -46,6 +47,7 @@ func NewServer(cfg config.Config) http.Handler {
|
||||
storeErr = fmt.Errorf("connect production mysql: %w", err)
|
||||
}
|
||||
} else {
|
||||
authDB = db
|
||||
var tdengine *sql.DB
|
||||
if cfg.TDengineDSN != "" {
|
||||
tdengine, err = platform.OpenSQL(ctx, cfg.TDengineDriver, cfg.TDengineDSN)
|
||||
@@ -92,7 +94,7 @@ func NewServer(cfg config.Config) http.Handler {
|
||||
// Reverse geocoding consumes the server-side AMap credential, so it must
|
||||
// stay behind the same authentication boundary as the platform API.
|
||||
api = withAMapReverseGeocodeAPI(api, cfg, "https://restapi.amap.com", http.DefaultClient)
|
||||
handler := static.Handler(cfg.StaticDir, withAPIAuth(api, cfg))
|
||||
handler := static.Handler(cfg.StaticDir, withAPIAuth(api, cfg, authDB))
|
||||
handler = withAppConfig(handler, cfg)
|
||||
handler = withAMapSecurityProxy(handler, cfg, defaultAMapProxyUpstreams(), http.DefaultClient)
|
||||
return withRequestTimeout(handler, cfg.RequestTimeout)
|
||||
|
||||
Reference in New Issue
Block a user