theorose49 df09d23662
All checks were successful
build-and-push / build (push) Successful in 35s
feat: 인턴 직급 + 초과근무 관리자 집계화 + SSO 로그아웃 URL + 디바이스/FCM
- 직급에 인턴 추가(기본 할당량 15), 직책(position)은 UI에서 제거(컬럼은 유지)
- 초과근무: 유저 신청 제거 → 관리자 근무관리에서 실제 출퇴근 기록 기반 자동 집계
- 로그아웃: infra 공통 LOGOUT_URL(/me로 전달) 사용 → oauth2-proxy 종료 + Keycloak end-session
- (이전 커밋 포함) Device 등록 + FCM HTTP v1 sender + notify 연동

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:55:53 +09:00

196 lines
5.7 KiB
Go

// Package push sends FCM (Firebase Cloud Messaging) notifications via the HTTP v1
// API using a service-account JSON — no third-party SDK, stdlib only.
//
// It is OPTIONAL: if no credentials are configured the Sender is "disabled" and
// Send is a no-op (logged). This lets spin run without Firebase until the client
// provides a service account.
package push
import (
"bytes"
"context"
"crypto"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
)
type serviceAccount struct {
ProjectID string `json:"project_id"`
PrivateKey string `json:"private_key"`
ClientEmail string `json:"client_email"`
TokenURI string `json:"token_uri"`
}
// Sender holds the FCM credentials and a cached OAuth access token.
type Sender struct {
sa *serviceAccount
key *rsa.PrivateKey
mu sync.Mutex
token string
expires time.Time
client *http.Client
}
// New builds a Sender from a service-account JSON file path. An empty path or a
// read error yields a disabled Sender (Send is a no-op).
func New(credsPath string) *Sender {
if strings.TrimSpace(credsPath) == "" {
log.Printf("push: FCM disabled (no credentials configured)")
return &Sender{}
}
raw, err := os.ReadFile(credsPath)
if err != nil {
log.Printf("push: FCM disabled (cannot read %s: %v)", credsPath, err)
return &Sender{}
}
var sa serviceAccount
if err := json.Unmarshal(raw, &sa); err != nil || sa.PrivateKey == "" || sa.ClientEmail == "" {
log.Printf("push: FCM disabled (invalid service account JSON)")
return &Sender{}
}
key, err := parsePrivateKey(sa.PrivateKey)
if err != nil {
log.Printf("push: FCM disabled (bad private key: %v)", err)
return &Sender{}
}
if sa.TokenURI == "" {
sa.TokenURI = "https://oauth2.googleapis.com/token"
}
log.Printf("push: FCM enabled (project=%s)", sa.ProjectID)
return &Sender{sa: &sa, key: key, client: &http.Client{Timeout: 10 * time.Second}}
}
// Enabled reports whether real sending is configured.
func (s *Sender) Enabled() bool { return s != nil && s.sa != nil }
// Send delivers a notification to each token. No-op when disabled. Errors per
// token are logged but don't abort the batch.
func (s *Sender) Send(ctx context.Context, tokens []string, title, body, link string) {
if !s.Enabled() || len(tokens) == 0 {
return
}
tok, err := s.accessToken(ctx)
if err != nil {
log.Printf("push: token error: %v", err)
return
}
endpoint := fmt.Sprintf("https://fcm.googleapis.com/v1/projects/%s/messages:send", s.sa.ProjectID)
for _, t := range tokens {
msg := map[string]any{
"message": map[string]any{
"token": t,
"notification": map[string]string{"title": title, "body": body},
"data": map[string]string{"link": link},
},
}
b, _ := json.Marshal(msg)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
log.Printf("push: send error: %v", err)
continue
}
if resp.StatusCode >= 300 {
rb, _ := io.ReadAll(resp.Body)
log.Printf("push: FCM %d: %s", resp.StatusCode, string(rb))
}
resp.Body.Close()
}
}
// accessToken returns a cached OAuth2 access token, minting a new one via the
// service-account JWT grant when expired.
func (s *Sender) accessToken(ctx context.Context) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.token != "" && time.Now().Before(s.expires.Add(-60*time.Second)) {
return s.token, nil
}
now := time.Now()
claims := map[string]any{
"iss": s.sa.ClientEmail,
"scope": "https://www.googleapis.com/auth/firebase.messaging",
"aud": s.sa.TokenURI,
"iat": now.Unix(),
"exp": now.Add(time.Hour).Unix(),
}
assertion, err := s.signJWT(claims)
if err != nil {
return "", err
}
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
form.Set("assertion", assertion)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, s.sa.TokenURI, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var out struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
if out.AccessToken == "" {
return "", fmt.Errorf("token exchange failed: %s", out.Error)
}
s.token = out.AccessToken
s.expires = now.Add(time.Duration(out.ExpiresIn) * time.Second)
return s.token, nil
}
func (s *Sender) signJWT(claims map[string]any) (string, error) {
header := map[string]string{"alg": "RS256", "typ": "JWT"}
hb, _ := json.Marshal(header)
cb, _ := json.Marshal(claims)
signingInput := b64(hb) + "." + b64(cb)
h := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(nil, s.key, crypto.SHA256, h[:])
if err != nil {
return "", err
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil
}
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func parsePrivateKey(pemStr string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return nil, errors.New("no PEM block")
}
if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return k, nil
}
keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
k, ok := keyAny.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("not an RSA private key")
}
return k, nil
}