Compare commits
No commits in common. "e3fe20005b2275427ac2a11874d99c233786a719" and "2063dffc09ad3747f87196435b79a367965ff900" have entirely different histories.
e3fe20005b
...
2063dffc09
@ -15,7 +15,6 @@ POSTGRES_USER=admin
|
|||||||
POSTGRES_PASSWORD=admin
|
POSTGRES_PASSWORD=admin
|
||||||
TZ=Asia/Tokyo
|
TZ=Asia/Tokyo
|
||||||
COMPOSE_PLATFORM=linux/arm64/v8
|
COMPOSE_PLATFORM=linux/arm64/v8
|
||||||
ROOT_ADMIN_UID=1
|
|
||||||
|
|
||||||
# App runtime (inside Docker network)
|
# App runtime (inside Docker network)
|
||||||
PGHOST=db
|
PGHOST=db
|
||||||
|
|||||||
@ -43,19 +43,19 @@ func main() {
|
|||||||
// 3) wiring
|
// 3) wiring
|
||||||
episodeRepo := repo.NewEpisodeRepo(pool)
|
episodeRepo := repo.NewEpisodeRepo(pool)
|
||||||
episodeSvc := service.NewEpisodeService(episodeRepo)
|
episodeSvc := service.NewEpisodeService(episodeRepo)
|
||||||
var authMgr auth.AdminManager
|
var verifier auth.TokenVerifier
|
||||||
if cfg.Auth.Enabled {
|
if cfg.Auth.Enabled {
|
||||||
v, err := auth.NewFirebaseAuth(ctx, cfg.Firebase)
|
v, err := auth.NewFirebaseAuth(ctx, cfg.Firebase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("firebase auth init failed: %v", err)
|
log.Fatalf("firebase auth init failed: %v", err)
|
||||||
}
|
}
|
||||||
authMgr = v
|
verifier = v
|
||||||
log.Printf("auth enabled (project: %s)", cfg.Firebase.ProjectID)
|
log.Printf("auth enabled (project: %s)", cfg.Firebase.ProjectID)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("auth disabled (AUTH_ENABLED=false)")
|
log.Printf("auth disabled (AUTH_ENABLED=false)")
|
||||||
}
|
}
|
||||||
|
|
||||||
router := httpapi.NewRouter(episodeSvc, authMgr, cfg.Auth.Enabled, cfg.Auth.RootUID)
|
router := httpapi.NewRouter(episodeSvc, verifier, cfg.Auth.Enabled)
|
||||||
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||||
|
|
||||||
// 4) HTTP server with timeouts
|
// 4) HTTP server with timeouts
|
||||||
|
|||||||
@ -16,12 +16,6 @@ type TokenVerifier interface {
|
|||||||
Verify(ctx context.Context, token string) (*fbauth.Token, error)
|
Verify(ctx context.Context, token string) (*fbauth.Token, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminManager can set admin claims.
|
|
||||||
type AdminManager interface {
|
|
||||||
TokenVerifier
|
|
||||||
SetAdmin(ctx context.Context, uid string, admin bool) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// FirebaseAuth verifies Firebase ID tokens.
|
// FirebaseAuth verifies Firebase ID tokens.
|
||||||
type FirebaseAuth struct {
|
type FirebaseAuth struct {
|
||||||
client *fbauth.Client
|
client *fbauth.Client
|
||||||
@ -52,10 +46,3 @@ func NewFirebaseAuth(ctx context.Context, cfg config.FirebaseConfig) (*FirebaseA
|
|||||||
func (f *FirebaseAuth) Verify(ctx context.Context, token string) (*fbauth.Token, error) {
|
func (f *FirebaseAuth) Verify(ctx context.Context, token string) (*fbauth.Token, error) {
|
||||||
return f.client.VerifyIDToken(ctx, token)
|
return f.client.VerifyIDToken(ctx, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAdmin sets or clears the custom claim "admin" for a given uid.
|
|
||||||
func (f *FirebaseAuth) SetAdmin(ctx context.Context, uid string, admin bool) error {
|
|
||||||
return f.client.SetCustomUserClaims(ctx, uid, map[string]interface{}{
|
|
||||||
"admin": admin,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@ -23,7 +23,6 @@ type Config struct {
|
|||||||
|
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
RootUID string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type FirebaseConfig struct {
|
type FirebaseConfig struct {
|
||||||
@ -51,7 +50,6 @@ func Load() Config {
|
|||||||
GinMode: getenv("GIN_MODE", "release"),
|
GinMode: getenv("GIN_MODE", "release"),
|
||||||
Auth: AuthConfig{
|
Auth: AuthConfig{
|
||||||
Enabled: getenvBool("AUTH_ENABLED", false),
|
Enabled: getenvBool("AUTH_ENABLED", false),
|
||||||
RootUID: getenv("ROOT_ADMIN_UID", ""),
|
|
||||||
},
|
},
|
||||||
Firebase: FirebaseConfig{
|
Firebase: FirebaseConfig{
|
||||||
ProjectID: getenv("FIREBASE_PROJECT_ID", ""),
|
ProjectID: getenv("FIREBASE_PROJECT_ID", ""),
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewRouter(svc episode.UseCases, authMgr auth.AdminManager, authEnabled bool, rootUID string) *gin.Engine {
|
func NewRouter(svc episode.UseCases, verifier auth.TokenVerifier, authEnabled bool) *gin.Engine {
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Logger(), gin.Recovery())
|
r.Use(gin.Logger(), gin.Recovery())
|
||||||
|
|
||||||
@ -32,17 +32,11 @@ func NewRouter(svc episode.UseCases, authMgr auth.AdminManager, authEnabled bool
|
|||||||
v1.POST("/oauth/firebase", oauthFirebaseHandler(verifier, authEnabled))
|
v1.POST("/oauth/firebase", oauthFirebaseHandler(verifier, authEnabled))
|
||||||
v1.GET("/danime", getDanimeHandler())
|
v1.GET("/danime", getDanimeHandler())
|
||||||
|
|
||||||
if authEnabled && authMgr != nil {
|
if authEnabled && verifier != nil {
|
||||||
protected := v1.Group("/")
|
protected := v1.Group("/")
|
||||||
protected.Use(AuthMiddleware(authMgr), RequireAdmin())
|
protected.Use(AuthMiddleware(verifier))
|
||||||
protected.DELETE("/shows", deleteShowsHandler(svc))
|
protected.DELETE("/shows", deleteShowsHandler(svc))
|
||||||
protected.POST("/archive", moveToArchiveHandler(svc))
|
protected.POST("/archive", moveToArchiveHandler(svc))
|
||||||
|
|
||||||
if rootUID != "" {
|
|
||||||
admin := v1.Group("/admin")
|
|
||||||
admin.Use(AuthMiddleware(authMgr), RequireRootUID(rootUID))
|
|
||||||
admin.POST("/grant", setAdminHandler(authMgr))
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
v1.DELETE("/shows", deleteShowsHandler(svc))
|
v1.DELETE("/shows", deleteShowsHandler(svc))
|
||||||
v1.POST("/archive", moveToArchiveHandler(svc))
|
v1.POST("/archive", moveToArchiveHandler(svc))
|
||||||
@ -51,38 +45,6 @@ func NewRouter(svc episode.UseCases, authMgr auth.AdminManager, authEnabled bool
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// setAdminHandler godoc
|
|
||||||
// @Summary Grant/revoke admin
|
|
||||||
// @Description Set the custom claim "admin" for a user. Requires root admin UID.
|
|
||||||
// @Tags admin
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param body body SetAdminReq true "UID and desired admin flag"
|
|
||||||
// @Success 204 {string} string "no content"
|
|
||||||
// @Failure 400 {object} HTTPError "invalid payload"
|
|
||||||
// @Failure 403 {object} HTTPError "forbidden"
|
|
||||||
// @Failure 500 {object} HTTPError "failed to set claim"
|
|
||||||
// @Router /api/v1/admin/grant [post]
|
|
||||||
func setAdminHandler(authMgr auth.AdminManager) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
var req SetAdminReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.UID) == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
admin := true
|
|
||||||
if req.Admin != nil {
|
|
||||||
admin = *req.Admin
|
|
||||||
}
|
|
||||||
if err := authMgr.SetAdmin(c.Request.Context(), req.UID, admin); err != nil {
|
|
||||||
log.Printf("set admin failed for uid=%s: %v", req.UID, err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set claim"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /healthz
|
// GET /healthz
|
||||||
func healthzHandler(c *gin.Context) {
|
func healthzHandler(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
fbauth "firebase.google.com/go/v4/auth"
|
|
||||||
"watch-party-backend/internal/auth"
|
"watch-party-backend/internal/auth"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@ -32,45 +31,3 @@ func AuthMiddleware(verifier auth.TokenVerifier) gin.HandlerFunc {
|
|||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequireAdmin enforces a custom claim "admin": true on the Firebase token.
|
|
||||||
func RequireAdmin() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
val, ok := c.Get("firebaseToken")
|
|
||||||
if !ok {
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token, ok := val.(*fbauth.Token)
|
|
||||||
if !ok {
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if isAdmin, ok := token.Claims["admin"].(bool); !ok || !isAdmin {
|
|
||||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequireRootUID enforces that the caller's UID matches the configured root admin UID.
|
|
||||||
func RequireRootUID(rootUID string) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
val, ok := c.Get("firebaseToken")
|
|
||||||
if !ok {
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token, ok := val.(*fbauth.Token)
|
|
||||||
if !ok || token.UID == "" {
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if token.UID != rootUID {
|
|
||||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -70,9 +70,3 @@ type FirebaseOAuthRes struct {
|
|||||||
Issuer string `json:"issuer,omitempty" example:"https://securetoken.google.com/<project>"`
|
Issuer string `json:"issuer,omitempty" example:"https://securetoken.google.com/<project>"`
|
||||||
Expires int64 `json:"expires,omitempty" example:"1700000000"`
|
Expires int64 `json:"expires,omitempty" example:"1700000000"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAdminReq is the request body for POST /api/v1/admin/grant.
|
|
||||||
type SetAdminReq struct {
|
|
||||||
UID string `json:"uid" binding:"required" example:"abc123"`
|
|
||||||
Admin *bool `json:"admin,omitempty" example:"true"`
|
|
||||||
}
|
|
||||||
|
|||||||
@ -78,12 +78,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
await signInWithPopup(auth, provider);
|
await signInWithPopup(auth, provider);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Avoid redirect fallback to prevent navigation/404; surface a clearer popup-blocked message.
|
|
||||||
const code = err?.code ?? "";
|
const code = err?.code ?? "";
|
||||||
if (code === "auth/popup-blocked") {
|
// Common cases where redirect works better
|
||||||
setError("ポップアップがブロックされました。ブラウザで許可してください。");
|
const redirectable = [
|
||||||
console.error("signin popup blocked:", err);
|
"auth/popup-blocked",
|
||||||
return;
|
"auth/operation-not-supported-in-this-environment",
|
||||||
|
"auth/unauthorized-domain",
|
||||||
|
];
|
||||||
|
if (redirectable.includes(code)) {
|
||||||
|
const { signInWithRedirect } = await import("firebase/auth");
|
||||||
|
await signInWithRedirect(auth, provider);
|
||||||
|
return; // navigation will happen
|
||||||
}
|
}
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
setError(msg); // keep the message for the UI
|
setError(msg); // keep the message for the UI
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user