theorose49 f83724b995
All checks were successful
build-and-push / build (push) Successful in 39s
feat: spin 백엔드 전체 구현 (근무·프로젝트·인센티브·회계)
- 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>
2026-06-28 08:57:35 +09:00

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
}