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
|
||||
|
||||
Reference in New Issue
Block a user