Files

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")
}