Compare commits
3 Commits
main
...
feature/au
| Author | SHA1 | Date | |
|---|---|---|---|
| e3fe20005b | |||
| 889f072390 | |||
| ff5c5f1b1d |
@ -15,6 +15,7 @@ POSTGRES_USER=admin
|
||||
POSTGRES_PASSWORD=admin
|
||||
TZ=Asia/Tokyo
|
||||
COMPOSE_PLATFORM=linux/arm64/v8
|
||||
ROOT_ADMIN_UID=1
|
||||
|
||||
# App runtime (inside Docker network)
|
||||
PGHOST=db
|
||||
|
||||
@ -43,19 +43,19 @@ func main() {
|
||||
// 3) wiring
|
||||
episodeRepo := repo.NewEpisodeRepo(pool)
|
||||
episodeSvc := service.NewEpisodeService(episodeRepo)
|
||||
var verifier auth.TokenVerifier
|
||||
var authMgr auth.AdminManager
|
||||
if cfg.Auth.Enabled {
|
||||
v, err := auth.NewFirebaseAuth(ctx, cfg.Firebase)
|
||||
if err != nil {
|
||||
log.Fatalf("firebase auth init failed: %v", err)
|
||||
}
|
||||
verifier = v
|
||||
authMgr = v
|
||||
log.Printf("auth enabled (project: %s)", cfg.Firebase.ProjectID)
|
||||
} else {
|
||||
log.Printf("auth disabled (AUTH_ENABLED=false)")
|
||||
}
|
||||
|
||||
router := httpapi.NewRouter(episodeSvc, verifier, cfg.Auth.Enabled)
|
||||
router := httpapi.NewRouter(episodeSvc, authMgr, cfg.Auth.Enabled, cfg.Auth.RootUID)
|
||||
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
|
||||
// 4) HTTP server with timeouts
|
||||
|
||||
@ -16,6 +16,12 @@ type TokenVerifier interface {
|
||||
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.
|
||||
type FirebaseAuth struct {
|
||||
client *fbauth.Client
|
||||
@ -46,3 +52,10 @@ func NewFirebaseAuth(ctx context.Context, cfg config.FirebaseConfig) (*FirebaseA
|
||||
func (f *FirebaseAuth) Verify(ctx context.Context, token string) (*fbauth.Token, error) {
|
||||
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,6 +23,7 @@ type Config struct {
|
||||
|
||||
type AuthConfig struct {
|
||||
Enabled bool
|
||||
RootUID string
|
||||
}
|
||||
|
||||
type FirebaseConfig struct {
|
||||
@ -50,6 +51,7 @@ func Load() Config {
|
||||
GinMode: getenv("GIN_MODE", "release"),
|
||||
Auth: AuthConfig{
|
||||
Enabled: getenvBool("AUTH_ENABLED", false),
|
||||
RootUID: getenv("ROOT_ADMIN_UID", ""),
|
||||
},
|
||||
Firebase: FirebaseConfig{
|
||||
ProjectID: getenv("FIREBASE_PROJECT_ID", ""),
|
||||
|
||||
@ -15,7 +15,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func NewRouter(svc episode.UseCases, verifier auth.TokenVerifier, authEnabled bool) *gin.Engine {
|
||||
func NewRouter(svc episode.UseCases, authMgr auth.AdminManager, authEnabled bool, rootUID string) *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
|
||||
@ -32,11 +32,17 @@ func NewRouter(svc episode.UseCases, verifier auth.TokenVerifier, authEnabled bo
|
||||
v1.POST("/oauth/firebase", oauthFirebaseHandler(verifier, authEnabled))
|
||||
v1.GET("/danime", getDanimeHandler())
|
||||
|
||||
if authEnabled && verifier != nil {
|
||||
if authEnabled && authMgr != nil {
|
||||
protected := v1.Group("/")
|
||||
protected.Use(AuthMiddleware(verifier))
|
||||
protected.Use(AuthMiddleware(authMgr), RequireAdmin())
|
||||
protected.DELETE("/shows", deleteShowsHandler(svc))
|
||||
protected.POST("/archive", moveToArchiveHandler(svc))
|
||||
|
||||
if rootUID != "" {
|
||||
admin := v1.Group("/admin")
|
||||
admin.Use(AuthMiddleware(authMgr), RequireRootUID(rootUID))
|
||||
admin.POST("/grant", setAdminHandler(authMgr))
|
||||
}
|
||||
} else {
|
||||
v1.DELETE("/shows", deleteShowsHandler(svc))
|
||||
v1.POST("/archive", moveToArchiveHandler(svc))
|
||||
@ -45,6 +51,38 @@ func NewRouter(svc episode.UseCases, verifier auth.TokenVerifier, authEnabled bo
|
||||
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
|
||||
func healthzHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
fbauth "firebase.google.com/go/v4/auth"
|
||||
"watch-party-backend/internal/auth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@ -31,3 +32,45 @@ func AuthMiddleware(verifier auth.TokenVerifier) gin.HandlerFunc {
|
||||
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,3 +70,9 @@ type FirebaseOAuthRes struct {
|
||||
Issuer string `json:"issuer,omitempty" example:"https://securetoken.google.com/<project>"`
|
||||
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,17 +78,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
try {
|
||||
await signInWithPopup(auth, provider);
|
||||
} catch (err: any) {
|
||||
// Avoid redirect fallback to prevent navigation/404; surface a clearer popup-blocked message.
|
||||
const code = err?.code ?? "";
|
||||
// Common cases where redirect works better
|
||||
const redirectable = [
|
||||
"auth/popup-blocked",
|
||||
"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
|
||||
if (code === "auth/popup-blocked") {
|
||||
setError("ポップアップがブロックされました。ブラウザで許可してください。");
|
||||
console.error("signin popup blocked:", err);
|
||||
return;
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg); // keep the message for the UI
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user