package httpapi import ( "context" "encoding/base64" "encoding/json" "net/http" "strings" ) // User is the authenticated principal carried on the request context. Identity // comes from oauth2-proxy (Keycloak) in production; spin matches it to a Member // row by email to resolve rank/role/admin within the app. type User struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` Role string `json:"role"` Groups []string `json:"groups,omitempty"` IsSuperAdmin bool `json:"isSuperAdmin"` } type ctxKey string const userKey ctxKey = "user" // devUser is the fixed identity injected when DEV_AUTH is enabled (local dev). var devUser = User{ID: "u-admin", Name: "관리자", Email: "theorose49@gmail.com", Role: "관리자", Groups: []string{"admin"}} // authMiddleware injects the authenticated user into the request context. // // - DEV_AUTH=true → a fixed mock identity (local development). // - DEV_AUTH=false → the identity forwarded by oauth2-proxy after a successful // Keycloak login (X-Forwarded-* / X-Auth-Request-* headers + token claims). // The backend trusts these headers because it is only reachable behind the // proxy; requests without them are rejected. func authMiddleware(devAuth bool, adminGroups []string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var u User if devAuth { u = devUser // Local dev runs as a super-admin so every screen is reachable. // Append ?as=user to any request to simulate a non-admin member. u.IsSuperAdmin = r.URL.Query().Get("as") != "user" if !u.IsSuperAdmin { u = User{ID: "u-member", Name: "김주임", Email: "member@special-partners.com", Role: "주임", Groups: []string{"user"}} } } else { var ok bool u, ok = userFromProxyHeaders(r) if !ok { writeError(w, http.StatusUnauthorized, "not authenticated (oauth2-proxy headers missing)") return } u.IsSuperAdmin = groupsIntersect(u.Groups, adminGroups) } ctx := context.WithValue(r.Context(), userKey, u) next.ServeHTTP(w, r.WithContext(ctx)) }) } } // groupsIntersect reports whether any of the user's groups is in adminGroups // (case-insensitive). func groupsIntersect(groups, adminGroups []string) bool { for _, g := range groups { for _, a := range adminGroups { if strings.EqualFold(strings.TrimSpace(g), strings.TrimSpace(a)) { return true } } } return false } // userFromProxyHeaders builds the principal from the headers oauth2-proxy // injects. It prefers the X-Forwarded-* headers (--pass-user-headers), falls // back to X-Auth-Request-* (--set-xauthrequest), and enriches the display name // and role from the forwarded access-token claims when available. func userFromProxyHeaders(r *http.Request) (User, bool) { hdr := func(keys ...string) string { for _, k := range keys { if v := strings.TrimSpace(r.Header.Get(k)); v != "" { return v } } return "" } email := hdr("X-Forwarded-Email", "X-Auth-Request-Email") username := hdr("X-Forwarded-Preferred-Username", "X-Auth-Request-Preferred-Username", "X-Forwarded-User", "X-Auth-Request-User") groups := splitGroups(hdr("X-Forwarded-Groups", "X-Auth-Request-Groups")) claims := decodeTokenClaims(r) if username == "" { username = firstNonEmpty(claims.PreferredUsername, claims.Sub) } if email == "" { email = claims.Email } if len(groups) == 0 { groups = splitGroups(strings.Join(claims.Groups, ",")) } // No identifying header at all → request did not come through oauth2-proxy. if username == "" && email == "" { return User{}, false } return User{ ID: firstNonEmpty(email, username, claims.Sub), Name: firstNonEmpty(claims.Name, username, email), Email: email, Role: deriveRole(groups, claims.RealmAccess.Roles), Groups: groups, }, true } // oidcClaims is the subset of Keycloak token claims we read. type oidcClaims struct { Sub string `json:"sub"` Name string `json:"name"` PreferredUsername string `json:"preferred_username"` Email string `json:"email"` Groups []string `json:"groups"` RealmAccess struct { Roles []string `json:"roles"` } `json:"realm_access"` } // decodeTokenClaims reads the forwarded access token and decodes its JWT payload // WITHOUT signature verification — oauth2-proxy already validated it upstream and // the backend is only reachable behind the proxy. Returns zero claims if absent. func decodeTokenClaims(r *http.Request) oidcClaims { var c oidcClaims tok := firstNonEmpty( r.Header.Get("X-Forwarded-Access-Token"), r.Header.Get("X-Auth-Request-Access-Token"), ) if tok == "" { if a := r.Header.Get("Authorization"); strings.HasPrefix(a, "Bearer ") { tok = strings.TrimSpace(strings.TrimPrefix(a, "Bearer ")) } } if tok == "" { return c } parts := strings.Split(tok, ".") if len(parts) < 2 { return c } payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { if payload, err = base64.URLEncoding.DecodeString(parts[1]); err != nil { return c } } _ = json.Unmarshal(payload, &c) return c } var ignoredRealmRoles = map[string]bool{"offline_access": true, "uma_authorization": true} // deriveRole picks a human-readable role for display: the first Keycloak group, // else the first non-default realm role, else "구성원". func deriveRole(groups, realmRoles []string) string { for _, g := range groups { if g != "" { return g } } for _, role := range realmRoles { if role == "" || ignoredRealmRoles[role] || strings.HasPrefix(role, "default-roles") { continue } return role } return "구성원" } func splitGroups(raw string) []string { var out []string for _, g := range strings.Split(raw, ",") { g = strings.TrimPrefix(strings.TrimSpace(g), "/") if g != "" { out = append(out, g) } } return out } func firstNonEmpty(vals ...string) string { for _, v := range vals { if strings.TrimSpace(v) != "" { return v } } return "" } // currentUser returns the authenticated user from context. func currentUser(ctx context.Context) User { if u, ok := ctx.Value(userKey).(User); ok { return u } return devUser }