diff --git a/inventory/oauth_client.go b/inventory/oauth_client.go index c9f6b3c0..0023f5fc 100644 --- a/inventory/oauth_client.go +++ b/inventory/oauth_client.go @@ -26,6 +26,9 @@ type ( UpsertGrant(ctx context.Context, userID, clientID int, scopes []string) error // UpdateGrantLastUsedAt updates the last used at for an OAuth grant for a user and client. UpdateGrantLastUsedAt(ctx context.Context, userID, clientID int) error + // DeleteGrantByUserAndClientGUID deletes an OAuth grant for a user by the client GUID. + // Returns true if the grant was deleted, false if it was not found. + DeleteGrantByUserAndClientGUID(ctx context.Context, userID int, clientGUID string) (bool, error) // List returns a paginated list of OAuth clients. List(ctx context.Context, args *ListOAuthClientArgs) (*ListOAuthClientResult, error) // GetByID returns the OAuth client by its ID. @@ -38,6 +41,8 @@ type ( Delete(ctx context.Context, id int) error // CountGrants returns the number of grants for an OAuth client. CountGrants(ctx context.Context, id int) (int, error) + // GetGrantsByUserID returns the OAuth grants for a user. + GetGrantsByUserID(ctx context.Context, userID int) ([]*ent.OAuthGrant, error) } ListOAuthClientArgs struct { @@ -50,6 +55,8 @@ type ( *PaginationResults Clients []*ent.OAuthClient } + + LoadOAuthGrantClient struct{} ) func NewOAuthClientClient(client *ent.Client, dbType conf.DBType) OAuthClientClient { @@ -111,6 +118,35 @@ func (c *oauthClientClient) UpdateGrantLastUsedAt(ctx context.Context, userID, c Exec(ctx) } +func (c *oauthClientClient) GetGrantsByUserID(ctx context.Context, userID int) ([]*ent.OAuthGrant, error) { + return withOAuthGrantEagerLoadings(ctx, c.client.OAuthGrant.Query()). + Where(oauthgrant.UserID(userID)). + All(ctx) +} + +func (c *oauthClientClient) DeleteGrantByUserAndClientGUID(ctx context.Context, userID int, clientGUID string) (bool, error) { + // First, get the client by GUID to get its ID + client, err := c.client.OAuthClient.Query(). + Where(oauthclient.GUID(clientGUID)). + First(ctx) + if err != nil { + if ent.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("failed to get OAuth client: %w", err) + } + + // Delete the grant for this user and client + deleted, err := c.client.OAuthGrant.Delete(). + Where(oauthgrant.UserID(userID), oauthgrant.ClientID(client.ID)). + Exec(ctx) + if err != nil { + return false, fmt.Errorf("failed to delete OAuth grant: %w", err) + } + + return deleted > 0, nil +} + func (c *oauthClientClient) List(ctx context.Context, args *ListOAuthClientArgs) (*ListOAuthClientResult, error) { query := c.client.OAuthClient.Query() @@ -233,3 +269,12 @@ func getOAuthClientOrderOption(args *ListOAuthClientArgs) []oauthclient.OrderOpt return []oauthclient.OrderOption{oauthclient.ByID(orderTerm)} } } + +func withOAuthGrantEagerLoadings(ctx context.Context, q *ent.OAuthGrantQuery) *ent.OAuthGrantQuery { + if v, ok := ctx.Value(LoadOAuthGrantClient{}).(bool); ok && v { + q.WithClient(func(ocq *ent.OAuthClientQuery) { + }) + } + + return q +} diff --git a/routers/controllers/oauth.go b/routers/controllers/oauth.go index 97b1ea4b..9c4472bf 100644 --- a/routers/controllers/oauth.go +++ b/routers/controllers/oauth.go @@ -66,3 +66,15 @@ func OpenIDUserInfo(c *gin.Context) { c.JSON(200, res) } + +func DeleteOAuthGrant(c *gin.Context) { + service := ParametersFromContext[*oauth.DeleteOAuthGrantService](c, oauth.DeleteOAuthGrantParamCtx{}) + err := service.Delete(c) + if err != nil { + c.JSON(200, serializer.Err(c, err)) + c.Abort() + return + } + + c.JSON(200, serializer.Response{}) +} diff --git a/routers/router.go b/routers/router.go index 88499cdc..347a407d 100644 --- a/routers/router.go +++ b/routers/router.go @@ -338,6 +338,12 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { controllers.FromQuery[oauth.UserInfoService](oauth.UserInfoParamCtx{}), controllers.OpenIDUserInfo, ) + oauthRouter.DELETE("grant/:app_id", + middleware.LoginRequired(), + middleware.RequiredScopes(types.ScopeUserSecurityInfoWrite), + controllers.FromUri[oauth.DeleteOAuthGrantService](oauth.DeleteOAuthGrantParamCtx{}), + controllers.DeleteOAuthGrant, + ) } authn := session.Group("authn") diff --git a/service/oauth/oauth.go b/service/oauth/oauth.go index adb01520..94c79de4 100644 --- a/service/oauth/oauth.go +++ b/service/oauth/oauth.go @@ -224,6 +224,38 @@ func (s *ExchangeTokenService) Exchange(c *gin.Context) (*TokenResponse, error) return resp, nil } +type ( + DeleteOAuthGrantParamCtx struct{} + DeleteOAuthGrantService struct { + AppID string `uri:"app_id" binding:"required"` + } +) + +func (s *DeleteOAuthGrantService) Delete(c *gin.Context) error { + dep := dependency.FromContext(c) + user := inventory.UserFromContext(c) + oAuthClient := dep.OAuthClientClient() + + // Delete the grant - the method validates that the grant belongs to the current user + deleted, err := oAuthClient.DeleteGrantByUserAndClientGUID(c, user.ID, s.AppID) + if err != nil { + return serializer.NewError(serializer.CodeDBError, "Failed to delete OAuth grant", err) + } + + if !deleted { + return serializer.NewError(serializer.CodeNotFound, "OAuth grant not found", nil) + } + + dep.AuditRecorder().Record(c, &types.LogEntry{ + Category: types.AuditLogTypeOAuthGrantRevoke, + Exts: map[string]string{ + "client_id": s.AppID, + }, + }) + + return nil +} + type ( UserInfoParamCtx struct{} UserInfoService struct{} diff --git a/service/user/response.go b/service/user/response.go index 6c30a2c0..ce359c80 100644 --- a/service/user/response.go +++ b/service/user/response.go @@ -22,17 +22,18 @@ type PreparePasskeyLoginResponse struct { } type UserSettings struct { - VersionRetentionEnabled bool `json:"version_retention_enabled"` - VersionRetentionExt []string `json:"version_retention_ext,omitempty"` - VersionRetentionMax int `json:"version_retention_max,omitempty"` - Paswordless bool `json:"passwordless"` - TwoFAEnabled bool `json:"two_fa_enabled"` - Passkeys []Passkey `json:"passkeys,omitempty"` - DisableViewSync bool `json:"disable_view_sync"` - ShareLinksInProfile string `json:"share_links_in_profile"` + VersionRetentionEnabled bool `json:"version_retention_enabled"` + VersionRetentionExt []string `json:"version_retention_ext,omitempty"` + VersionRetentionMax int `json:"version_retention_max,omitempty"` + Paswordless bool `json:"passwordless"` + TwoFAEnabled bool `json:"two_fa_enabled"` + Passkeys []Passkey `json:"passkeys,omitempty"` + DisableViewSync bool `json:"disable_view_sync"` + ShareLinksInProfile string `json:"share_links_in_profile"` + OAuthGrants []OauthGrant `json:"oauth_grants,omitempty"` } -func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Parser) *UserSettings { +func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Parser, grants []*ent.OAuthGrant) *UserSettings { return &UserSettings{ VersionRetentionEnabled: u.Settings.VersionRetention, VersionRetentionExt: u.Settings.VersionRetentionExt, @@ -44,6 +45,9 @@ func BuildUserSettings(u *ent.User, passkeys []*ent.Passkey, parser *uaparser.Pa }), DisableViewSync: u.Settings.DisableViewSync, ShareLinksInProfile: string(u.Settings.ShareLinksInProfile), + OAuthGrants: lo.Map(grants, func(item *ent.OAuthGrant, index int) OauthGrant { + return BuildOauthGrant(item) + }), } } @@ -172,6 +176,31 @@ func BuildUser(user *ent.User, idEncoder hashid.Encoder) User { } } +type OauthGrant struct { + ClientID string `json:"client_id"` + ClientName string `json:"client_name"` + ClientLogo string `json:"client_logo"` + Scopes []string `json:"scopes"` + LastUsedAt *time.Time `json:"last_used_at"` +} + +func BuildOauthGrant(grant *ent.OAuthGrant) OauthGrant { + res := OauthGrant{ + Scopes: grant.Scopes, + LastUsedAt: grant.LastUsedAt, + } + + if grant.Edges.Client != nil { + res.ClientID = grant.Edges.Client.GUID + res.ClientName = grant.Edges.Client.Name + if grant.Edges.Client.Props != nil { + res.ClientLogo = grant.Edges.Client.Props.Icon + } + } + + return res +} + func BuildGroup(group *ent.Group, idEncoder hashid.Encoder) *Group { if group == nil { return nil diff --git a/service/user/setting.go b/service/user/setting.go index 70ab7db3..58d821c9 100644 --- a/service/user/setting.go +++ b/service/user/setting.go @@ -131,7 +131,13 @@ func GetUserSettings(c *gin.Context) (*UserSettings, error) { return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user passkey", err) } - return BuildUserSettings(u, passkeys, dep.UAParser()), nil + ctx := context.WithValue(c, inventory.LoadOAuthGrantClient{}, true) + grants, err := dep.OAuthClientClient().GetGrantsByUserID(ctx, u.ID) + if err != nil { + return nil, serializer.NewError(serializer.CodeDBError, "Failed to get user OAuth grants", err) + } + + return BuildUserSettings(u, passkeys, dep.UAParser(), grants), nil // 用户组有效期