Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 13 additions & 47 deletions cmd/backend/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,32 +35,6 @@ func AuthStorage(redisClient rueidis.Client) auth.Storage {
return auth.NewRedisStorage(redisClient)
}

// AuthMiddleware creates an auth.Middleware that can be injected into gin.
func AuthMiddleware(storage auth.Storage) Middleware {
return Middleware{
Handler: auth.Middleware(storage),
}
}

// MachineMiddleware creates a machine middleware that can be injected into gin.
func MachineMiddleware() Middleware {
return Middleware{
Handler: httputils.MachineMiddleware(),
}
}

// CorsMiddleware creates a cors middleware that can be injected into gin.
func CorsMiddleware(cfg config.Config) Middleware {
return Middleware{
Handler: cors.New(cors.Config{
AllowOrigins: cfg.AllowedOrigins,
AllowMethods: []string{"GET", "POST", "OPTIONS"},
AllowHeaders: []string{"Content-Type", "User-Agent", "Referer"},
AllowCredentials: true,
}),
}
}

func SqlRunner(cfg config.Config) *sqlrunner.SqlRunner {
return sqlrunner.NewSqlRunner(cfg.SqlRunner)
}
Expand Down Expand Up @@ -92,24 +66,29 @@ func AuthService(entClient *ent.Client, storage auth.Storage, config config.Conf
}

// GinEngine creates a gin engine.
func GinEngine(services []httpapi.Service, middlewares []Middleware, gqlgenHandler *handler.Server, cfg config.Config) *gin.Engine {
func GinEngine(services []httpapi.Service, authStorage auth.Storage, gqlgenHandler *handler.Server, cfg config.Config) *gin.Engine {
engine := gin.New()

if err := engine.SetTrustedProxies(cfg.TrustProxies); err != nil {
slog.Error("error setting trusted proxies", "error", err)
}

for _, middleware := range middlewares {
engine.Use(middleware.Handler)
}

engine.Use(gin.Recovery())

engine.GET("/", func(ctx *gin.Context) {
engine.Use(httputils.MachineMiddleware())
engine.Use(cors.New(cors.Config{
AllowOrigins: cfg.AllowedOrigins,
AllowMethods: []string{"GET", "POST", "OPTIONS"},
AllowHeaders: []string{"Content-Type", "User-Agent", "Referer"},
AllowCredentials: true,
}))

router := engine.Group("/")
router.Use(auth.Middleware(authStorage))
router.GET("/", func(ctx *gin.Context) {
handler := playground.Handler("GraphQL playground", "/query")
handler.ServeHTTP(ctx.Writer, ctx.Request)
})
engine.POST("/query", func(ctx *gin.Context) {
router.POST("/query", func(ctx *gin.Context) {
gqlgenHandler.ServeHTTP(ctx.Writer, ctx.Request)
})

Expand Down Expand Up @@ -174,19 +153,6 @@ func GinLifecycle(lifecycle fx.Lifecycle, engine *gin.Engine, cfg config.Config)
})
}

// Middleware is a middleware that can be injected into gin.
type Middleware struct {
Handler gin.HandlerFunc
}

// AnnotateMiddleware annotates a middleware function to be injected into gin.
func AnnotateMiddleware(f any) any {
return fx.Annotate(
f,
fx.ResultTags(`group:"middlewares"`),
)
}

// AnnotateService annotates a service function to be injected into gin.
func AnnotateService(f any) any {
return fx.Annotate(
Expand Down
5 changes: 1 addition & 4 deletions cmd/backend/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,11 @@ func main() {
fx.Provide(
AuthStorage,
SqlRunner,
AnnotateMiddleware(AuthMiddleware),
AnnotateMiddleware(MachineMiddleware),
AnnotateMiddleware(CorsMiddleware),
AnnotateService(AuthService),
GqlgenHandler,
fx.Annotate(
GinEngine,
fx.ParamTags(`group:"services"`, `group:"middlewares"`),
fx.ParamTags(`group:"services"`),
),
),
fx.Invoke(GinLifecycle),
Expand Down
8 changes: 4 additions & 4 deletions graph/user.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,14 @@ extend type Mutation {
impersonateUser(userID: ID!): String! @scope(scope: "user:impersonate")

"""
Logout from all the devices of the current user.
Logout a user from all his devices.
"""
logoutAll: Boolean! @scope(scope: "me:write")
logoutUser(userID: ID!): Boolean! @scope(scope: "user:write")

"""
Delete the current user.
Logout from all the devices of the current user.
"""
deleteMe: Boolean! @scope(scope: "me:delete")
logoutAll: Boolean! @scope(scope: "me:write")

"""
Verify the registration of this user.
Expand Down
25 changes: 6 additions & 19 deletions graph/user.resolvers.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

159 changes: 0 additions & 159 deletions graph/user.resolvers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1251,165 +1251,6 @@ func TestMutationResolver_UpdateMe(t *testing.T) {
})
}

func TestMutationResolver_DeleteMe(t *testing.T) {
t.Run("success", func(t *testing.T) {
entClient := testhelper.NewEntSqliteClient(t)

// Setup database with proper groups and scopes
_, err := setup.Setup(context.Background(), entClient)
require.NoError(t, err)

// Get the new-user group (which has me:delete scope)
newUserGroup, err := entClient.Group.Query().Where(group.NameEQ(useraccount.NewUserGroupSlug)).Only(context.Background())
require.NoError(t, err)

// Create a test user in new-user group
user, err := entClient.User.Create().
SetName("testuser").
SetEmail("test@example.com").
SetGroup(newUserGroup).
Save(context.Background())
require.NoError(t, err)

resolver := &Resolver{
ent: entClient,
auth: &mockAuthStorage{},
}

// Create test server with scope directive
cfg := Config{
Resolvers: resolver,
Directives: DirectiveRoot{Scope: directive.ScopeDirective},
}
srv := handler.New(NewExecutableSchema(cfg))
srv.AddTransport(transport.POST{})
c := client.New(srv)

// Execute mutation
var resp struct {
DeleteMe bool
}
err = c.Post(`mutation { deleteMe }`, &resp, func(bd *client.Request) {
bd.HTTP = bd.HTTP.WithContext(auth.WithUser(bd.HTTP.Context(), auth.TokenInfo{
UserID: user.ID,
Scopes: []string{"me:delete"},
}))
})

// Verify response
require.NoError(t, err)
require.True(t, resp.DeleteMe)

// Verify user was actually deleted
_, err = entClient.User.Get(context.Background(), user.ID)
require.Error(t, err)
require.True(t, ent.IsNotFound(err))
})

t.Run("unauthenticated", func(t *testing.T) {
entClient := testhelper.NewEntSqliteClient(t)
resolver := &Resolver{
ent: entClient,
auth: &mockAuthStorage{},
}

// Create test server with scope directive
cfg := Config{
Resolvers: resolver,
Directives: DirectiveRoot{Scope: directive.ScopeDirective},
}
srv := handler.New(NewExecutableSchema(cfg))
srv.AddTransport(transport.POST{})
c := client.New(srv)

// Execute mutation with no auth context
var resp struct {
DeleteMe bool
}
err := c.Post(`mutation { deleteMe }`, &resp)

// Verify error
require.Error(t, err)
require.Contains(t, err.Error(), defs.ErrUnauthorized.Error())
})

t.Run("insufficient scope", func(t *testing.T) {
entClient := testhelper.NewEntSqliteClient(t)

resolver := &Resolver{
ent: entClient,
auth: &mockAuthStorage{},
}

// Create test server with scope directive
cfg := Config{
Resolvers: resolver,
Directives: DirectiveRoot{Scope: directive.ScopeDirective},
}
srv := handler.New(NewExecutableSchema(cfg))
srv.AddTransport(transport.POST{})
c := client.New(srv)

// Create context with authenticated user but wrong scope
ctx := auth.WithUser(context.Background(), auth.TokenInfo{
UserID: 1,
Scopes: []string{"user:read"},
})

// Execute mutation
var resp struct {
DeleteMe bool
}
err := c.Post(`mutation { deleteMe }`, &resp, func(bd *client.Request) {
bd.HTTP = bd.HTTP.WithContext(ctx)
})

// Verify error
require.Error(t, err)
require.Contains(t, err.Error(), defs.NewErrNoSufficientScope("me:delete").Error())
})

t.Run("user not found", func(t *testing.T) {
entClient := testhelper.NewEntSqliteClient(t)

// Setup database with proper groups and scopes
_, err := setup.Setup(context.Background(), entClient)
require.NoError(t, err)

resolver := &Resolver{
ent: entClient,
auth: &mockAuthStorage{},
}

// Create test server with scope directive
cfg := Config{
Resolvers: resolver,
Directives: DirectiveRoot{Scope: directive.ScopeDirective},
}
srv := handler.New(NewExecutableSchema(cfg))
srv.AddTransport(transport.POST{})
c := client.New(srv)

// Create context with authenticated user but non-existent user ID
ctx := auth.WithUser(context.Background(), auth.TokenInfo{
UserID: 999, // Non-existent user ID
Scopes: []string{"me:delete"},
})

// Execute mutation
var resp struct {
DeleteMe bool
}
err = c.Post(`mutation { deleteMe }`, &resp, func(bd *client.Request) {
bd.HTTP = bd.HTTP.WithContext(ctx)
})

// Verify error
require.Error(t, err)
require.Contains(t, err.Error(), useraccount.ErrUserNotFound.Error())
})
}

func TestMutationResolver_VerifyRegistration(t *testing.T) {
t.Run("success", func(t *testing.T) {
entClient := testhelper.NewEntSqliteClient(t)
Expand Down
3 changes: 3 additions & 0 deletions httpapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,7 @@

Database Playground 大部分的 API 均以 GraphQL 形式提供 (`/query`),但部分為 BFF (Backend for Frontend) 設定的 Stateful Endpoints 則是以 HTTP API 進行設計,並以 `/api` 為開頭。

> [!WARNING]
> 注意 HTTP API 不會帶入 AuthMiddleware。如果你的 API 需要鑒權,請手動帶入 `auth.Middleware`。

- [認證](./auth):相關方法均列於 `/api/auth` 路徑底下。
10 changes: 2 additions & 8 deletions httpapi/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,6 @@ Auth 端點提供適合供網頁應用程式使用的認證 API。

您可以使用 `POST /api/logout` 登出帳號。

如果沒有 Auth Token 或者是 token 無效,會回傳如這種結構的 HTTP 401 錯誤:

```json
{
"error": "You should be logged in to logout.",
}
```

如果 Token 撤回失敗,則會回傳 HTTP 500 錯誤並帶上錯誤資訊:

```json
Expand All @@ -25,6 +17,8 @@ Auth 端點提供適合供網頁應用程式使用的認證 API。

如果 Token 撤回成功,則回傳 HTTP 205 (Reset Content),此時您可以重新整理登入狀態。

如果沒有 Auth Token 或者是 token 無效,則依然回傳 HTTP 205。請引導使用者重新登入。

## Google 登入

如果您要觸發 Google 登入的流程,請前往 `GET /api/auth/google/login`。可以帶入 `redirect_uri` 參數來在登入完成後轉導到指定畫面。
Expand Down
Loading
Loading