Files
knowledge-governance/internal/httpapi/auth.go
T
姚章浩 e60b10256a fix(settings): add DingTalk spaces and render the real login QR
Address two gaps in the first DingTalk-session UI:
- add an explicit admin-only '+ 新增钉钉知识库' action on the data-source
  panel; it accepts an alidocs space URL, resolves the live name/root through
  the authenticated sidecar, writes the inventory root atomically, upserts a
  deterministic source connection, and enqueues its first catalog refresh
- read DingTalk's real QR canvas pixels rather than taking a DOM screenshot;
  select by dark-pixel content, composite onto a white quiet zone, and render
  at 320x320 so the dialog contains a large complete code, not a page thumbnail

The live refresh path resolves registered roots from the connection locator,
while existing name-based DingTalk sources remain compatible.
2026-09-30 10:54:07 +08:00

193 lines
6.9 KiB
Go

package httpapi
import (
"context"
"net/http"
"strings"
"time"
postgresstore "knowledge-governance/internal/platform/postgres"
)
// Session identity injected by the auth middleware. Business handlers read it
// to stamp actor fields server-side; the browser never types a name.
type sessionIdentity struct {
UserID string
Username string
DisplayName string
Role string
MustChangePassword bool
SessionID string
}
type sessionKey struct{}
func sessionFrom(ctx context.Context) (sessionIdentity, bool) {
value, ok := ctx.Value(sessionKey{}).(sessionIdentity)
return value, ok
}
const sessionCookieName = "kg_session"
const (
roleViewer = postgresstore.RoleViewer
roleEngineer = postgresstore.RoleEngineer
roleAdmin = postgresstore.RoleAdmin
)
func roleRank(role string) int {
switch role {
case roleAdmin:
return 2
case roleEngineer:
return 1
default:
return 0
}
}
// AuthStore is the account/session capability the HTTP layer needs; the
// concrete postgres *Store implements it, and fakes keep handler tests cheap.
type AuthStore interface {
Login(ctx context.Context, username, password, userAgent string, now time.Time, ttl time.Duration) (postgresstore.LoginOutcome, error)
LookupSession(ctx context.Context, token string, now time.Time) (postgresstore.AuthSession, error)
Logout(ctx context.Context, sessionID string, now time.Time) error
TouchSession(ctx context.Context, sessionID string, now, expiresAt time.Time) error
ChangePassword(ctx context.Context, userID, oldPassword, newPassword string, now time.Time) error
CreateUser(ctx context.Context, username, displayName, role, password, createdBy string, now time.Time) (postgresstore.AuthUser, error)
GetAuthUserByID(ctx context.Context, id string) (postgresstore.AuthUser, error)
ListAuthUsers(ctx context.Context) ([]postgresstore.AuthUser, error)
SetUserRole(ctx context.Context, actorID, userID, role string, now time.Time) error
SetUserState(ctx context.Context, actorID, userID, state string, now time.Time) error
AdminResetPassword(ctx context.Context, actorID, userID, temporaryPassword string, now time.Time) error
}
// AuthConfig wires the account/session store into the HTTP layer. When the
// server's Auth field is nil, auth stays disabled (unit tests and the local
// job-debug socket); production main must always provide it.
type AuthConfig struct {
Store AuthStore
Now func() time.Time
SessionTTL time.Duration
}
func (auth AuthConfig) sessionTTL() time.Duration {
if auth.SessionTTL > 0 {
return auth.SessionTTL
}
return defaultSessionTTL
}
func (auth AuthConfig) now() time.Time {
if auth.Now != nil {
return auth.Now()
}
return time.Now()
}
func isPublicEndpoint(method, path string) bool {
switch path {
case "/health", "/livez", "/readyz":
return true
}
return path == "/api/v1/auth/login" && method == http.MethodPost
}
func isAuthEndpoint(method, path string) bool {
return strings.HasPrefix(path, "/api/v1/auth/")
}
// classify maps a request to the minimum role required.
func classifyRole(method, path string) string {
// The member roster exposes account identities and roles: admin-only for
// every method, checked before the read shortcut below.
if strings.HasPrefix(path, "/api/v1/users") {
return roleAdmin
}
if method == http.MethodGet {
return roleViewer
}
// Production authority is a role, not a typed name: both double-gated
// authorizations require admin on top of the environment switches.
if strings.HasSuffix(path, "/authorize-production") || strings.HasSuffix(path, "/authorize-rollback") ||
(strings.HasPrefix(path, "/api/v1/release-executions/") && strings.HasSuffix(path, "/retry")) {
return roleAdmin
}
// Connector settings revisions are admin-only.
if strings.Contains(path, "/settings/revisions") {
return roleAdmin
}
// The DingTalk scan session replaces the shared browser credentials:
// admin-only, like every other credential surface. The QR image is a
// login artifact, so even polling it stays admin-only. Space
// registration writes new source connections: same boundary.
if strings.HasPrefix(path, "/api/v1/dingtalk-session") || strings.HasPrefix(path, "/api/v1/dingtalk-spaces") {
return roleAdmin
}
// Binding the internal/external knowledge bases re-routes every future
// annotation: admin-only, like the roster and production authority.
if path == "/api/v1/routing-bindings" && method != http.MethodGet {
return roleAdmin
}
// Everything else that mutates (review, batch, seal, confirm, start, retry,
// route visibility, disable) is engineer work; viewers cannot POST at all.
return roleEngineer
}
// authenticate wraps the mux: cookie session → context identity → lockdown →
// RBAC. Business handlers assume a valid identity exists in the context.
func (auth AuthConfig) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isPublicEndpoint(r.Method, r.URL.Path) {
next.ServeHTTP(w, r)
return
}
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
session, err := auth.Store.LookupSession(r.Context(), cookie.Value, auth.now())
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
identity := sessionIdentity{
UserID: session.User.ID, Username: session.User.Username, DisplayName: session.User.DisplayName,
Role: session.User.Role, MustChangePassword: session.User.MustChangePassword, SessionID: session.ID,
}
if session.User.MustChangePassword && !isAuthEndpoint(r.Method, r.URL.Path) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "password change required"})
return
}
if !isAuthEndpoint(r.Method, r.URL.Path) {
if roleRank(identity.Role) < roleRank(classifyRole(r.Method, r.URL.Path)) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "insufficient role"})
return
}
}
ctx := context.WithValue(r.Context(), sessionKey{}, identity)
expiresAt := auth.now().UTC().Add(auth.sessionTTL())
if err := auth.Store.TouchSession(r.Context(), session.ID, auth.now().UTC(), expiresAt); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not refresh session"})
return
}
auth.setSessionCookie(w, cookie.Value, expiresAt)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (auth AuthConfig) setSessionCookie(w http.ResponseWriter, token string, expiresAt time.Time) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Value: token, Path: "/",
Expires: expiresAt, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
}
func (auth AuthConfig) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, Expires: time.Unix(0, 0),
HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
}