260 lines
9.5 KiB
Go
260 lines
9.5 KiB
Go
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)
|
|
})
|
|
}
|
|
|
|
// RequireSameOriginFrame authenticates browser iframe navigation that cannot
|
|
// attach the X-Product header used by API clients. Fetch Metadata keeps this
|
|
// narrow: only a same-origin iframe navigation with a valid Sense session is
|
|
// accepted.
|
|
func RequireSameOriginFrame(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.RequireSameOriginFrame(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) RequireSameOriginFrame(permission string, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Sec-Fetch-Site") != "same-origin" ||
|
|
r.Header.Get("Sec-Fetch-Mode") != "navigate" ||
|
|
r.Header.Get("Sec-Fetch-Dest") != "iframe" {
|
|
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 || !HasPermission(principal.Role, permission) {
|
|
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) 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")
|
|
}
|