package identity import ( "context" "crypto/rand" "errors" "net/http" "strings" "sync/atomic" "time" "yovision.local/sense/internal/platform" ) const sessionCookieName = "sense_session" type principalContextKey struct{} type Module struct { service *Service cfg Config } var activeModule atomic.Pointer[Module] func NewModule(service *Service, cfg Config) *Module { return &Module{service: service, cfg: cfg} } func (m *Module) Register(app *platform.App) { activeModule.Store(m) app.HandleFunc("POST /api/v1/identity/bootstrap", m.bootstrap) app.HandleFunc("POST /api/v1/identity/login", m.login) app.Handle("POST /api/v1/identity/logout", m.Authenticated(http.HandlerFunc(m.logout))) app.Handle("GET /api/v1/identity/me", m.Authenticated(http.HandlerFunc(m.me))) app.Handle("GET /api/v1/identity/users", m.Require(PermissionUsersRead, http.HandlerFunc(m.users))) app.Handle("POST /api/v1/identity/users", m.Require(PermissionUsersWrite, http.HandlerFunc(m.users))) app.Handle("PATCH /api/v1/identity/users/{id}", m.Require(PermissionUsersWrite, http.HandlerFunc(m.updateUser))) app.Handle("GET /api/v1/identity/audit", m.Require(PermissionAuditRead, http.HandlerFunc(m.audit))) } func Require(permission string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { module := activeModule.Load() if module == nil { platform.WriteError(w, &platform.APIError{Status: http.StatusServiceUnavailable, Code: "identity_not_ready", Message: "身份服务尚未就绪"}) return } module.Require(permission, next).ServeHTTP(w, r) }) } func PrincipalFromContext(ctx context.Context) (Principal, bool) { principal, ok := ctx.Value(principalContextKey{}).(Principal) return principal, ok } func (m *Module) Authenticated(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-Product") != "sense" { platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "登录状态无效"}) return } cookie, err := r.Cookie(sessionCookieName) if err != nil { platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "登录状态无效"}) return } principal, err := m.service.Authenticate(r.Context(), cookie.Value) if err != nil { platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "登录状态无效"}) return } next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), principalContextKey{}, principal))) }) } func (m *Module) Require(permission string, next http.Handler) http.Handler { return m.Authenticated(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { principal, _ := PrincipalFromContext(r.Context()) if !HasPermission(principal.Role, permission) { platform.WriteError(w, &platform.APIError{Status: http.StatusForbidden, Code: "forbidden", Message: "没有执行此操作的权限"}) return } next.ServeHTTP(w, r) })) } func (m *Module) bootstrap(w http.ResponseWriter, r *http.Request) { var request struct { Username string `json:"username"` DisplayName string `json:"display_name"` Password string `json:"password"` } if err := platform.DecodeJSON(r, &request); err != nil { platform.WriteError(w, err) return } user, err := m.service.Bootstrap(r.Context(), r.Header.Get("X-Sense-Bootstrap-Token"), request.Username, request.DisplayName, request.Password) if err != nil { platform.WriteError(w, &platform.APIError{Status: http.StatusForbidden, Code: "bootstrap_denied", Message: "安全初始化不可用或输入不符合要求"}) return } platform.WriteJSON(w, http.StatusCreated, user) } func (m *Module) login(w http.ResponseWriter, r *http.Request) { var request struct { Username string `json:"username"` Password string `json:"password"` } if err := platform.DecodeJSON(r, &request); err != nil { platform.WriteError(w, err) return } principal, token, err := m.service.Login(r.Context(), request.Username, request.Password) if err != nil { // A small constant delay and one generic message reduce account discovery. time.Sleep(80*time.Millisecond + randomJitter()) platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "invalid_credentials", Message: "用户名或密码不正确"}) return } http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: token, Path: "/api/v1", HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteStrictMode, MaxAge: int(m.cfg.SessionTTL.Seconds())}) platform.WriteJSON(w, http.StatusOK, principal) } func randomJitter() time.Duration { var value [1]byte _, _ = rand.Read(value[:]) return time.Duration(value[0]%40) * time.Millisecond } func (m *Module) logout(w http.ResponseWriter, r *http.Request) { principal, _ := PrincipalFromContext(r.Context()) if err := m.service.Logout(r.Context(), principal); err != nil { platform.WriteError(w, err) return } http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/api/v1", HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteStrictMode, MaxAge: -1}) w.WriteHeader(http.StatusNoContent) } func (m *Module) me(w http.ResponseWriter, r *http.Request) { principal, _ := PrincipalFromContext(r.Context()) platform.WriteJSON(w, http.StatusOK, principal) } func (m *Module) users(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet { users, err := m.service.ListUsers(r.Context()) if err != nil { platform.WriteError(w, err) return } platform.WriteJSON(w, http.StatusOK, map[string]any{"items": users, "total": len(users)}) return } var request struct { Username string `json:"username"` DisplayName string `json:"display_name"` Role string `json:"role"` Password string `json:"password"` } if err := platform.DecodeJSON(r, &request); err != nil { platform.WriteError(w, err) return } principal, _ := PrincipalFromContext(r.Context()) user, err := m.service.CreateUser(r.Context(), principal, request.Username, request.DisplayName, request.Role, request.Password) if err != nil { platform.WriteError(w, &platform.APIError{Status: http.StatusBadRequest, Code: "user_not_created", Message: err.Error()}) return } platform.WriteJSON(w, http.StatusCreated, user) } func (m *Module) updateUser(w http.ResponseWriter, r *http.Request) { var request struct { DisplayName string `json:"display_name"` Role string `json:"role"` Enabled bool `json:"enabled"` } if err := platform.DecodeJSON(r, &request); err != nil { platform.WriteError(w, err) return } principal, _ := PrincipalFromContext(r.Context()) user, err := m.service.UpdateUser(r.Context(), principal, r.PathValue("id"), request.DisplayName, request.Role, request.Enabled) if err != nil { platform.WriteError(w, &platform.APIError{Status: http.StatusBadRequest, Code: "user_not_updated", Message: err.Error()}) return } platform.WriteJSON(w, http.StatusOK, user) } func (m *Module) audit(w http.ResponseWriter, r *http.Request) { entries, err := m.service.ListAudit(r.Context()) if err != nil { platform.WriteError(w, err) return } platform.WriteJSON(w, http.StatusOK, map[string]any{"items": entries, "total": len(entries)}) } func SigningKeyOrRandom(raw string, allowRandom bool) ([]byte, error) { if len(raw) >= 32 { return []byte(raw), nil } if !allowRandom { return nil, errors.New("SENSE_IDENTITY_SIGNING_KEY must contain at least 32 bytes") } key := make([]byte, 32) if _, err := rand.Read(key); err != nil { return nil, err } return key, nil } func SecureCookieFromEnvironment(value string, memoryMode bool) bool { if memoryMode { return strings.EqualFold(value, "true") } return !strings.EqualFold(value, "false") }