All checks were successful
build-and-push / build (push) Successful in 39s
- config/db/storage/auth/router/perms: eQMS 규약 미러링, 권한 2-tier (관리자 전체 / 구성원 본인·신청만), oauth2-proxy 헤더 인증 + DEV_AUTH mock - 모델: 구성원/부서, 근무(출퇴근·휴가·공가·초과), 프로젝트(회사/제품/버전· 작업자portion·담당자·태스크·계약·첨부·분할입금), 인센티브(설정·단계· 유저배분·분기정산), 회계(거래·세금) - internal/worktime: 근로기준법 월 집계 엔진 - internal/incentive: BE/non-BE × 계약금/중도금/잔금 3단계 계산 + 시뮬레이션 - 시드 데이터, Go 멀티스테이지 Dockerfile - ADMIN_GROUPS 기본값 'admin' (전 내부 앱 공통 그룹) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
210 lines
6.2 KiB
Go
210 lines
6.2 KiB
Go
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
|
|
}
|