feat(auth): implement admin management with RootUID enforcement and SetAdmin handler
This commit is contained in:
parent
889f072390
commit
e3fe20005b
@ -15,6 +15,7 @@ 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 verifier auth.TokenVerifier
|
var authMgr auth.AdminManager
|
||||||
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)
|
||||||
}
|
}
|
||||||
verifier = v
|
authMgr = 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, verifier, cfg.Auth.Enabled)
|
router := httpapi.NewRouter(episodeSvc, authMgr, cfg.Auth.Enabled, cfg.Auth.RootUID)
|
||||||
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,6 +16,12 @@ 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
|
||||||
@ -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) {
|
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,6 +23,7 @@ type Config struct {
|
|||||||
|
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
|
RootUID string
|
||||||
}
|
}
|
||||||
|
|
||||||
type FirebaseConfig struct {
|
type FirebaseConfig struct {
|
||||||
@ -50,6 +51,7 @@ 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, 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 := gin.New()
|
||||||
r.Use(gin.Logger(), gin.Recovery())
|
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.POST("/oauth/firebase", oauthFirebaseHandler(verifier, authEnabled))
|
||||||
v1.GET("/danime", getDanimeHandler())
|
v1.GET("/danime", getDanimeHandler())
|
||||||
|
|
||||||
if authEnabled && verifier != nil {
|
if authEnabled && authMgr != nil {
|
||||||
protected := v1.Group("/")
|
protected := v1.Group("/")
|
||||||
protected.Use(AuthMiddleware(verifier), RequireAdmin())
|
protected.Use(AuthMiddleware(authMgr), RequireAdmin())
|
||||||
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))
|
||||||
@ -45,6 +51,38 @@ func NewRouter(svc episode.UseCases, verifier auth.TokenVerifier, authEnabled bo
|
|||||||
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"})
|
||||||
|
|||||||
@ -53,3 +53,24 @@ func RequireAdmin() gin.HandlerFunc {
|
|||||||
c.Next()
|
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>"`
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user